diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 87f47e8ebe..b136076bc8 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,5 +1,5 @@ use super::error::{Result, ToolError, failed}; -use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; +use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels}; use super::paths::{read_input, write_atomic}; use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; @@ -234,6 +234,34 @@ impl Automation { out["next"] = listing["next"].clone(); out } + "text_styles" => { + // The samples are the document's text, as text_lines is. + let doc = self.doc_to_copy(&a)?; + let styles: Vec = doc + .text_styles() + .into_iter() + .map(|u| { + json!({ + "font": u.style.font, + "size": u.style.size, + "bold": u.style.bold, + "italic": u.style.italic, + "lines": u.lines, + "pages": u.pages, + "sample": u.sample, + }) + }) + .collect(); + json!({ "styles": styles }) + } + "bookmark_from_text_styles" => { + let levels = text_style_levels(&a)?; + let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?; + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); + out + } "doc_protect" => self.doc_protect(&a)?, "page_replace" => { let pages = self.pages(&a, "pages")?; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 6f0884226a..9e93d4c347 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0; // ---- JSON helpers ---------------------------------------------------------------------------- +/// The most bookmark levels `bookmark_from_text_styles` takes. +const MAX_STYLE_LEVELS: usize = 9; + +/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each +/// `{ font, size, bold, italic }` as text_styles lists them. +pub(crate) fn text_style_levels(a: &Args) -> Result> { + let levels = + a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?; + if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS { + return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles"))); + } + levels + .iter() + .enumerate() + .map(|(i, v)| { + let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}")); + let font = + v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?; + let size = + v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?; + let flag = |key: &str| match v.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(b)) => Ok(*b), + Some(_) => Err(bad(&format!("{key} must be true or false"))), + }; + Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? }) + }) + .collect() +} + pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result> { let Some(value) = value else { return Ok(Vec::new()) }; let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c4994aace1..c67c2ecdbe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -347,6 +347,39 @@ pub fn tools() -> Vec { "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", ) .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "text_styles", + "List text styles", + "The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.", + ) + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t( + "bookmark_from_text_styles", + "New bookmarks from text styles", + "Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "levels": { + "type": "array", + "minItems": 1, + "maxItems": 9, + "items": { + "type": "object", + "properties": { + "font": { "type": "string", "minLength": 1 }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }, + "required": ["font", "size"] + } + } + }), + &["doc", "levels"], + )), t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( json!({ "doc": doc(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 382bda1f4b..88dd4e3376 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() { assert_eq!(result["status"], "passed"); } +/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18, +/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693). +fn styled_headings_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()), + format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the +/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why +/// when it can't. +#[test] +fn bookmarks_from_text_styles_through_tools() { + let dir = workdir("text-style-bookmarks"); + std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone(); + assert_eq!( + styles, + json!([ + { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" }, + { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" }, + { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" }, + ]) + ); + // The styles as text_styles gave them, the chapters first. + let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]); + let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels })); + let top = &r["bookmarks"][0]; + assert_eq!(top["title"], "Untitled"); + let titles: Vec<(&str, u64)> = + top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect(); + assert_eq!(titles, [("Introduction", 1), ("Results", 2)]); + assert_eq!(top["children"][0]["children"][0]["title"], "Background"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles"); + // What can't be done says why. + let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels })); + assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size"))); + assert!( + matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold")) + ); + let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }])); + assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}"); + let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect(); + assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_)))); +} + /// A one-page PDF whose form holds one text field named `name`. fn form_pdf(name: &str) -> Vec { let objs: Vec = vec![ diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2412658240..6bfe9fa826 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"), + // Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693). + c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"), c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"), c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index e66b0d0f17..eaa8f70807 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -237,6 +237,16 @@ impl Document { self.generation } + /// 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| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default() + } + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). pub fn text_lines(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index c31984ca19..2c64f7bcd6 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -122,6 +122,11 @@ pub enum Edit { /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, + /// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests + /// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]). + BookmarksFromTextStyles { + levels: Vec, + }, /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. NumberPages { from: usize, diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index ea25898068..9edccb3125 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Colour", "Style", "New bookmarks from structure", + "New bookmarks from text styles", "Number pages", "Measure distance", "Measure perimeter", @@ -186,6 +187,7 @@ impl Edit { Edit::SetBookmarkColor { .. } => Label::of("Colour"), Edit::SetBookmarkStyle { .. } => Label::of("Style"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"), Edit::NumberPages { .. } => Label::of("Number pages"), // What was measured is part of the wording too, for the same reason. Edit::AddMeasurement(m) => Label::of(match m.kind { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 49f19fa637..1d02a258a8 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -32,6 +32,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re 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 { .. } @@ -280,10 +291,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None }) .collect(); pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; } + Edit::BookmarksFromTextStyles { levels } => { + if levels.is_empty() { + return Err(EditError::Invalid("choose a text style for at least one bookmark level".into())); + } + // Each line goes to the first level whose style it is in: a style given again, or one + // within the size tolerance of an earlier level's, would leave its level empty. + for (i, a) in levels.iter().enumerate() { + if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) { + let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2)); + return Err(EditError::Invalid(format!( + "bookmark levels {first} and {second} have the same text style: choose a different one for each level" + ))); + } + } + let entries = crate::text_style_bookmarks::headings(doc, levels); + if entries.is_empty() { + return Err(EditError::Invalid("no text is set in the chosen styles".into())); + } + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, Edit::AddMeasurement(m) => { measure::add(doc, m, &cx.meta())?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 889b839368..024058ecc2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -54,6 +54,7 @@ mod stamp_preview; mod test_hooks; pub mod text_fonts; mod text_preview; +pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; mod xfa_form; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 84e295d364..ecaf82efc3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1849,6 +1849,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// Three pages whose headings are set apart only by how they look (#1693): chapters in +/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title +/// runs over two lines, its bold font is another subset of the same font, and one section is set +/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row. +fn styled_headings_fixture() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \ + BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET"; + let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \ + BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \ + BT /FB 14 Tf 72 600 Td (Section C) Tj ET"; + // Two sections on one row, the right one drawn first: read from the left. + let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET"; + let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>"; + pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>")) + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>") + .stream("", page1) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .stream("", page2) + .obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>") + .stream("", page3) + .build() +} + +/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones: +/// chapters at level 1, sections nested under the chapter before them. A two-line title is one +/// bookmark; a subset of the same font and a size set by the text matrix count as the style. +/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused. +#[test] +fn bookmarks_from_text_styles_nest_the_headings_and_undo() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let listed: Vec<(String, f64, bool, usize, usize)> = + styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect(); + assert_eq!( + listed, + [ + ("Helvetica-Bold".to_string(), 18.0, true, 3, 2), + ("Helvetica-Bold".to_string(), 14.0, true, 5, 3), + ("Helvetica".to_string(), 11.0, false, 3, 2), + ] + ); + assert_eq!(styles[0].sample, "Chapter One"); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap(); + let outline = s.get(id).unwrap().info.outline.clone(); + assert_eq!( + outline_titles(&outline), + ["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"] + ); + let first = &outline[0].children[0]; + match first.view { + pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => { + assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}") + } + ref v => panic!("{v:?}"), + } + assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + s.undo(id).unwrap(); + assert!(s.get(id).unwrap().info.outline.is_empty()); + // One level is enough; the sections are then left out. + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]); + s.undo(id).unwrap(); + // Nothing chosen, or a style no text is in: refused, nothing changes. + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err(); + assert!(err.to_string().contains("at least one bookmark level"), "{err}"); + let unused = pdfcraft_engine_style(§ion, 30.0); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err(); + assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}"); + assert!(s.get(id).unwrap().info.outline.is_empty()); +} + +/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style +/// for two levels (or one within the size tolerance of the other) would leave the later level +/// empty. Refused at the edit, for the dialog and automation alike; nothing changes. +#[test] +fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap(); + let styles = s.get(id).unwrap().text_styles(); + let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone()); + let near = pdfcraft_engine_style(§ion, section.size + 0.1); + for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] { + let n = levels.len(); + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err(); + assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}"); + 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() } +} + #[test] fn number_pages_shows_in_the_viewer_and_undoes() { let (mut s, id) = session_with(5); diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs new file mode 100644 index 0000000000..5969188d67 --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,229 @@ +//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how +//! they look (chapter titles in one font at one size, sections in another), not tagged as +//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the +//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level. +//! +//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are +//! subsets of one font), the size the text is shown at, and whether it is bold and italic. +//! Lines are taken in reading order down each page: top to bottom, then left to right. + +use pdfcraft_cos::Document; +use pdfcraft_organize::OutlineEntry; + +/// How a line of text looks. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyle { + /// The font's name, without a subset tag. + pub font: String, + /// The size the text is shown at, in points (rounded to a tenth). + pub size: f64, + pub bold: bool, + pub italic: bool, +} + +impl TextStyle { + /// Whether `other` is this style. Sizes within a quarter point match, as the same heading + /// size is often written with rounding differences (12 and 11.96). + pub(crate) fn matches(&self, other: &TextStyle) -> bool { + self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE + } +} + +/// How far apart two sizes may be and still be one style. +const SIZE_TOLERANCE: f64 = 0.25; + +/// A style and how much text is set in it. +#[derive(Clone, Debug, PartialEq)] +pub struct TextStyleUse { + pub style: TextStyle, + /// Lines in this style. + pub lines: usize, + /// Pages they are on. + pub pages: usize, + /// The first line in it, cut to [`SAMPLE_CHARS`] characters. + pub sample: String, +} + +/// The most pages read: a longer document is listed and searched by its first pages. +pub const MAX_PAGES: usize = 5000; +/// The most bookmarks made at once. +pub const MAX_BOOKMARKS: usize = 10_000; +/// The longest bookmark title kept, in characters (a heading run on into a paragraph). +const MAX_TITLE_CHARS: usize = 300; +/// The longest sample shown for a style. +const SAMPLE_CHARS: usize = 60; +/// The most styles listed, the largest first. +const MAX_STYLES: usize = 200; + +/// One line of text in reading order. +struct Line { + page: usize, + text: String, + style: TextStyle, + /// 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). +fn without_subset_tag(name: &str) -> &str { + match name.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest, + _ => name, + } +} + +/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that +/// is only whitespace, or whose size can't be read, is left out. +fn lines(doc: &Document) -> Vec { + let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES); + let mut out = Vec::new(); + for page in 0..pages { + let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue }; + let mut on_page: Vec = found + .into_iter() + .filter_map(|l| { + let text = l.text.split_whitespace().collect::>().join(" "); + // `size` is the size the line is shown at in user space (the `Tf` size times the + // text rendering matrix), so `1 Tf` with a 14× text matrix is 14. + let size = l.size; + let [x0, y0, x1, y1] = l.rect; + let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite()); + ok.then(|| Line { + page, + text, + style: TextStyle { + font: without_subset_tag(&l.base_font).to_string(), + size: (size * 10.0).round() / 10.0, + bold: l.bold, + italic: l.italic, + }, + top: y0.max(y1), + bottom: y0.min(y1), + left: x0.min(x1), + right: x0.max(x1), + }) + }) + .collect(); + // Down the page; along each row from the left. Boxes of one row differ by a fraction + // of a point, so rows are compared at a tenth of a point. + on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left))); + out.extend(on_page); + } + out +} + +/// The styles `doc`'s text is set in, the largest size first (then the most used): headings +/// 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) { + 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) { + u.pages = u.pages.saturating_add(1); + *last_page = Some(line.page); + } + } + 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))); + } + } + } + let mut uses: Vec = uses.into_iter().map(|(u, _)| u).collect(); + uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines))); + uses.truncate(MAX_STYLES); + uses +} + +/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1 +/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A +/// heading set on several lines (one after another in the same style, close below each other) +/// 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, 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 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; + 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() + && e.title.chars().count() < MAX_TITLE_CHARS + { + e.title.push(' '); + e.title.push_str(&line.text); + e.title = e.title.chars().take(MAX_TITLE_CHARS).collect(); + } + } else { + if entries.len() >= MAX_BOOKMARKS { + break; + } + // The level counts from 1; the position in `levels` is at most its length, which a + // caller keeps small (a few levels), so it fits a u8. + let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX); + entries.push(OutlineEntry { + level, + title: line.text.chars().take(MAX_TITLE_CHARS).collect(), + page: line.page, + element: None, + top: Some(line.top), + }); + } + open = Some((level, line.top, line.bottom, line.left, line.right)); + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subset_tags_are_left_out_of_the_font_name() { + assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold"); + assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters"); + assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters"); + assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag"); + assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x"); + } + + #[test] + fn styles_match_within_a_quarter_point() { + let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false }; + assert!(s(12.0).matches(&s(11.8))); + assert!(!s(12.0).matches(&s(11.7))); + assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) })); + assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) })); + } +} diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 2bb4122d2f..ff7d3d8da7 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -518,14 +518,10 @@ fn destination_at(doc: &Document, page: usize, left: Option, top: Option Object { - go_to_at(page, None, None) -} - /// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the -/// reader's zoom; a coordinate that is left out keeps the reader's position. +/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither, +/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a +/// new bookmark when the view has no specific position). fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) @@ -569,7 +565,7 @@ pub fn add_bookmark_at( Ok(path) } -/// One bookmark of a generated tree (New Bookmarks from Structure). +/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles). #[derive(Clone, Debug, PartialEq)] pub struct OutlineEntry { /// 1 = directly under the new parent; deeper levels nest under the last shallower entry. @@ -578,6 +574,9 @@ pub struct OutlineEntry { pub page: usize, /// The structure element it stands for (`/SE`). pub element: Option, + /// Where on the page it goes: this height (user space) at the top of the window, at the + /// reader's zoom. `None` goes to the page, keeping the reader's position. + pub top: Option, } /// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by @@ -601,7 +600,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl for (e, page) in entries.iter().zip(targets) { let mut d = Dict::new(); d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title))); - d.set(b"Dest".to_vec(), go_to(page)); + d.set(b"Dest".to_vec(), go_to_at(page, None, e.top)); if let Some(se) = e.element { d.set(b"SE".to_vec(), Object::Ref(se)); } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1fd60d744a..64391bb82d 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -1789,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() { fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { let mut d = doc_a(); crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap(); - let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element }; + let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None }; let se = ObjRef::new(4242, 0); let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)]; assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f846b489b8..686a458aa6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -222,6 +222,7 @@ impl PdfCraftApp { pending_full_screen: None, pending_remote_link: None, protect_draft: Default::default(), + text_style_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), export_draft: Default::default(), diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 0889a447d2..07a36ac7f4 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -371,6 +371,7 @@ impl PdfCraftApp { } "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), + "bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles), "page.organize" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { if v.organize { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2a1c16003c..e607bfa690 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::Edit; use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, widgets}; pub(super) mod appearance; +pub mod bookmark_styles_ui; pub(super) mod comment_props; pub(super) mod credits; pub(super) mod marks_ui; @@ -421,6 +422,10 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::BookmarksFromTextStyles => { + req.close = crate::bookmark_styles_ui::body(ui, app, &t); + return false; + } Dialog::NumberPages => { if !dialog_number_pages(ui, app, req) { return false; diff --git a/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs new file mode 100644 index 0000000000..d022babb6f --- /dev/null +++ b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs @@ -0,0 +1,134 @@ +//! Bookmarks ▸ New bookmarks from text styles (#1693): for documents whose headings aren't +//! tagged, a style per bookmark level (the chapters' font and size for level 1, the sections' +//! for level 2, …), and every line set in it becomes a bookmark of that level +//! ([`pdfcraft_engine::text_style_bookmarks`]). + +use pdfcraft_engine::text_style_bookmarks::{TextStyle, TextStyleUse}; +use pdfcraft_engine::{DocId, Edit}; + +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, widgets}; + +/// The levels offered: deeper outlines are rare, and each level needs a style of its own. +const LEVELS: usize = 4; + +/// What the dialog shows and what has been chosen in it. +#[derive(Debug, Default)] +pub struct TextStyleDraft { + /// The document the styles were read from. + pub doc: Option, + /// Its text styles, the largest first. + pub styles: Vec, + /// The style (an index into `styles`) for each level, level 1 first; a level without one + /// ends the list. + pub levels: [Option; LEVELS], +} + +impl TextStyleDraft { + /// The styles for the chosen levels, level 1 first, up to the first level left without one. + pub fn chosen(&self) -> Vec { + self.levels.iter().map_while(|l| l.and_then(|i| self.styles.get(i)).map(|u| u.style.clone())).collect() + } +} + +impl PdfCraftApp { + /// Open New bookmarks from text styles for document `id`: its styles are read now, and the + /// largest is offered for level 1 when there is more than one (headings are larger than the + /// text around them; a single style has no headings to tell apart). + pub fn open_text_style_bookmarks(&mut self, id: DocId) { + // The style list includes samples copied from the document's text. Enforce the PDF's + // copy permission before extracting any of that text, not only when creating bookmarks. + let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy())); + if !can_copy { + self.notify_tr("The document's security settings don't allow copying text and images"); + return; + } + let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default(); + let first = (styles.len() > 1).then_some(0); + self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] }; + self.dialog = Some(crate::Dialog::BookmarksFromTextStyles); + } +} + +/// How a style is named in the lists: font, size, bold or italic, how many lines, and a sample. +fn style_label(u: &TextStyleUse) -> String { + let mut s = crate::i18n::fmt(tl!("{font}, {size} pt"), &[("font", &u.style.font), ("size", &format!("{}", u.style.size))]); + if u.style.bold { + s.push_str(", "); + s.push_str(tl!("bold")); + } + if u.style.italic { + s.push_str(", "); + s.push_str(tl!("italic")); + } + let lines = if u.lines == 1 { tl!("1 line").to_string() } else { crate::i18n::fmt(tl!("{n} lines"), &[("n", &u.lines.to_string())]) }; + let sample: String = u.sample.chars().take(40).collect(); + let more = if sample.chars().count() < u.sample.chars().count() { "…" } else { "" }; + format!("{s} · “{sample}{more}” ({lines})") +} + +/// The dialog's contents and its buttons. Returns whether it closes; Create makes the +/// bookmarks and shows them in the Bookmarks panel. +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { + ui.label(egui::RichText::new(tl!("New bookmarks from text styles")).font(theme::semibold(18.0))); + ui.add_space(6.0); + let d = &mut app.text_style_draft; + let id = d.doc; + if d.styles.is_empty() { + ui.label(tl!("This document has no text to make bookmarks from.")); + ui.add_space(12.0); + return widgets::pill_button(ui, tl!("Close"), true).clicked(); + } + ui.label( + egui::RichText::new(tl!("Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark.")) + .color(t.text_muted), + ); + ui.add_space(8.0); + let none = tl!("None"); + egui::Grid::new("text_style_levels").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for level in 0..LEVELS { + // A level takes a style only once the one above it has one. + let open = level == 0 || d.levels.get(level - 1).copied().flatten().is_some(); + let label = ui.label(crate::i18n::fmt(tl!("Level {n}"), &[("n", &(level + 1).to_string())])); + ui.add_enabled_ui(open, |ui| { + let Some(slot) = d.levels.get_mut(level) else { return }; + let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label); + craft_ui::ComboBox::from_id_salt(("text_style_level", level)) + .selected_text(shown) + .width(460.0) + .show_ui(ui, |ui| { + ui.selectable_value(slot, None, none); + for (i, u) in d.styles.iter().enumerate() { + ui.selectable_value(slot, Some(i), style_label(u)); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + } + }); + // A level left without a style ends the list: the ones below it don't count. + if let Some(gap) = d.levels.iter().position(Option::is_none) { + d.levels.iter_mut().skip(gap).for_each(|l| *l = None); + } + let levels = d.chosen(); + ui.add_space(16.0); + let mut close = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(!levels.is_empty(), |ui| widgets::pill_button(ui, tl!("Create"), true)).inner.clicked() { + close = true; + // The document the styles came from, even if another tab is active by now. + if let Some(i) = id.and_then(|id| app.views.iter().position(|v| v.id == id)) { + app.active = Some(i); + if app.apply_edit(Edit::BookmarksFromTextStyles { levels }) { + app.right = Some(crate::RightPanel::Bookmarks); + } + } + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + }); + close +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4d09b5cf4b..c370f9a407 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -730,6 +730,11 @@ impl PdfCraftApp { self.right = Some(crate::RightPanel::Bookmarks); Edit::BookmarksFromStructure } + // A dialog chooses the styles; it makes the edit. + A::FromTextStyles => { + self.open_text_style_bookmarks(id); + return; + } }; self.apply_edit(edit); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71dd140752..aac9a11862 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -82,6 +82,7 @@ use canvas::wheel_boost; use canvas::wheel_pager; use canvas::zoom_snap; use dialogs::appearance; +pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; use dialogs::credits; use dialogs::marks_ui; @@ -573,6 +574,8 @@ pub struct PdfCraftApp { pub(crate) word_count_draft: word_count_ui::Draft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, + /// New bookmarks from text styles dialog state (#1693). + pub text_style_draft: bookmark_styles_ui::TextStyleDraft, /// Set Page Boxes dialog state. pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7c5b2a03d7..b6dcc67858 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -194,6 +194,7 @@ const PAGES: &[&str] = &[ SEP, "bookmark.add", "bookmark.from_structure", + "bookmark.from_text_styles", ]; /// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar. const FILL: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index ec61bf2fe2..ccc45d4eb0 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -21,6 +21,8 @@ pub enum BmAction { Outdent(Vec), /// Bookmarks from the tagged headings, under a new first "Untitled" bookmark. FromStructure, + /// Open New bookmarks from text styles, to choose a style per bookmark level (#1693). + FromTextStyles, } /// Matching titles plus their ancestors, keeping document paths rather than filtered indexes. diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 0470543acb..9729818033 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -107,6 +107,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { bm_action = Some(BmAction::FromStructure); ui.close(); } + if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() { + bm_action = Some(BmAction::FromTextStyles); + ui.close(); + } if !info.outline.is_empty() { for (levels, label) in [ (usize::MAX, tl!("Expand all bookmarks")), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d41d511a97..03df25f201 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -113,6 +113,8 @@ pub enum Dialog { PageProps, /// Protect Using Password. Protect, + /// Bookmarks ▸ New bookmarks from text styles (#1693). + BookmarksFromTextStyles, /// Set Page Boxes (crop, trim, bleed, art, media). PageBoxes, /// Add / Update Header and Footer, Watermark, Background. diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 415067ad33..1f07102a18 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -250,6 +250,97 @@ trailer << /Root 1 0 R >> } } +/// Two pages whose chapter and section headings are set apart only by style (#1693). +fn styled_pdf() -> Vec { + let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET BT /FB 14 Tf 72 640 Td (Background) Tj ET"; + let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET"; + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj +5 0 obj << /Length {} >> +stream +{page1} +endstream +endobj +6 0 obj << /Length {} >> +stream +{page2} +endstream +endobj +7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> endobj +8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj +trailer << /Root 1 0 R >> +%%EOF", + page1.len(), + page2.len() + ) + .into_bytes() +} + +/// #1693: Bookmark options ▸ New bookmarks from text styles… lists the document's styles with a +/// sample, offers the largest for level 1, and Create makes the bookmarks (sections under +/// their chapter) as one undo step. Cancel changes nothing; a document without text says so. +#[test] +fn new_bookmarks_from_text_styles_from_the_bookmark_options() { + let mut h = harness(); + h.state_mut().open_bytes("styled.pdf", None, styled_pdf()).unwrap(); + h.run_steps(3); + let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.get_by_label("Bookmark options").click(); + h.run_steps(2); + h.get_by_label("New bookmarks from text styles…").click(); + h.run_steps(3); + }; + open(&mut h); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); + assert_eq!(h.state().text_style_draft.levels, [Some(0), None, None, None], "the largest style is offered for level 1"); + // Cancel: nothing made. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert!(h.state().session.get(id).unwrap().info.outline.is_empty()); + // Level 2 from its list: the sections' style. + open(&mut h); + // Each level's list is named after it, for screen readers too. + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Level 2").click(); + h.run_steps(2); + // It lists every style, by font, size, weight and a sample. + h.get_by_label("Helvetica-Bold, 18 pt, bold · “Introduction” (2 lines)"); + h.get_by_label("Helvetica, 11 pt · “Some text” (2 lines)"); + h.run_steps(2); + h.get_by_label("Helvetica-Bold, 14 pt, bold · “Background” (1 line)").click(); + h.run_steps(2); + assert_eq!(h.state().text_style_draft.levels, [Some(0), Some(1), None, None]); + h.get_by_label("Create").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let outline = h.state().session.get(id).unwrap().info.outline.clone(); + assert_eq!(outline.len(), 1); + assert_eq!(outline[0].title, "Untitled"); + let titles: Vec<(&str, Option)> = outline[0].children.iter().map(|o| (o.title.as_str(), o.page)).collect(); + assert_eq!(titles, [("Introduction", Some(0)), ("Results", Some(1))]); + assert_eq!(outline[0].children[0].children[0].title, "Background"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from text styles")); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); + // A document with no text: nothing to choose from. + h.state_mut().open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap(); + h.run_steps(3); + open(&mut h); + let message = h.get_by_label("This document has no text to make bookmarks from.").rect(); + // The dialog's Close, below the message (the panel has a Close of its own). + h.get_all_by_label("Close").find(|n| n.rect().top() > message.bottom()).expect("the dialog's Close").click(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + // The command (Pages menu, command palette) opens it too. + assert!(h.state_mut().execute("bookmark.from_text_styles")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles)); +} + #[test] fn expanding_and_collapsing_all_bookmarks() { use pdfcraft_engine::Edit; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 15a2ed7d8d..cabeb5421a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3378,6 +3378,18 @@ tools = ["bookmark_from_structure"] evidence = ["crates/a11y/src/tests.rs::headings_are_found_with_their_levels_titles_and_pages", "crates/a11y/src/tests.rs::headings_drawn_glyph_by_glyph_keep_their_words", "crates/a11y/src/tests.rs::horizontally_scaled_wrapped_heading_keeps_a_space_between_lines", "crates/organize/src/tests.rs::a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark", "crates/engine/src/tests.rs::bookmarks_from_structure_nest_the_headings_and_undo", "crates/automation/tests/it/automation.rs::bookmarks_from_structure_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_structure_nest_the_tagged_headings"] notes = "Bookmark options ▸ New bookmarks from structure: tagged headings (H1–H6 through the role map; a plain H takes its Sect/Part/Art nesting) become bookmarks nested by level under a new first \"Untitled\" bookmark, each linked to its element (/SE). Titles come from /ActualText, the marked text drawn directly on the page (not inside Form XObjects; words split where the glyphs are apart, so text placed glyph by glyph, as Chromium writes it, keeps its words; vertical line breaks use the absolute font size independently of Tz), /Alt or /T. Not yet: Acrobat's choice of element types (only headings), and destinations go to the heading's page rather than its position on it" +[[feature]] +id = "organize.bookmarks-from-text-styles" +title = "Generate bookmarks from text styles" +area = "D" +tier = "P2" +milestone = "M4" +status = "shipped" +commands = ["bookmark.from_text_styles"] +tools = ["text_styles", "bookmark_from_text_styles"] +evidence = ["crates/engine/src/tests.rs::bookmarks_from_text_styles_nest_the_headings_and_undo", "crates/engine/src/text_style_bookmarks.rs::subset_tags_are_left_out_of_the_font_name", "crates/engine/src/text_style_bookmarks.rs::styles_match_within_a_quarter_point", "crates/automation/tests/it/automation.rs::bookmarks_from_text_styles_through_tools", "crates/ui-egui/tests/it/bookmarks.rs::new_bookmarks_from_text_styles_from_the_bookmark_options"] +notes = "Bookmark options (and Pages) ▸ New bookmarks from text styles…, for documents whose headings aren't tagged (#1693): the dialog lists the document's text styles (font without its subset tag, the size shown at, bold, italic, line count and a sample), the largest first and offered for level 1; one style per level, up to four levels (nine headlessly). Every line in a level's style becomes a bookmark of that level under a new first \"Untitled\" bookmark, a heading set on several rows is one bookmark, and each goes to the top of its heading (/XYZ). Lines are read down each page, then left to right, so a multi-column page's headings are taken row by row across the columns; sizes within a quarter point match. Not yet: several styles for one level, colour as part of a style, and the first 5000 pages only. Acrobat has no such command (its bookmarks come from structure); this is beyond it" + [[feature]] id = "organize.attachments-add" title = "Add file attachments"