From 4ce68f517b072ee96bf83319e2ccced1a5971b4d Mon Sep 17 00:00:00 2001 From: Lcoding15 <125505497+Lcoding15@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:03:08 +0200 Subject: [PATCH 1/6] M4: New bookmarks from text styles, for headings that aren't tagged (#1693) Bookmark options > New bookmarks from text styles... lists the styles the document's text is set in (font without its subset tag, the size shown, bold, italic, how many lines, a sample), the largest first, and takes one style per bookmark level. Every line in a level's style becomes a bookmark of that level under a new first "Untitled" bookmark, as from structure; a heading set on several rows is one bookmark, and each goes to the top of its heading. The detection lives in engine::text_style_bookmarks on top of edit::text_lines, reading each page top to bottom then left to right, so two headings on one row stay two. text_styles and bookmark_from_text_styles reach it headlessly, and the command bookmark.from_text_styles opens the dialog. OutlineEntry gets an optional top for the /XYZ destination; structure bookmarks keep theirs. --- crates/automation/src/lib.rs | 58 ++++++ crates/automation/src/tools.rs | 33 ++++ crates/automation/tests/it/automation.rs | 72 ++++++++ crates/engine/src/commands.rs | 2 + crates/engine/src/lib.rs | 27 ++- crates/engine/src/tests.rs | 83 +++++++++ crates/engine/src/text_style_bookmarks.rs | 211 ++++++++++++++++++++++ crates/organize/src/outline.rs | 17 +- crates/organize/src/tests.rs | 2 +- crates/ui-egui/src/bookmark_styles_ui.rs | 127 +++++++++++++ crates/ui-egui/src/commands.rs | 1 + crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/editing.rs | 5 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels.rs | 6 + crates/ui-egui/tests/it/bookmarks.rs | 91 ++++++++++ parity/acrobat-features.toml | 12 ++ 17 files changed, 746 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/text_style_bookmarks.rs create mode 100644 crates/ui-egui/src/bookmark_styles_ui.rs diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e94354021f..30caf10ddc 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -421,6 +421,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")?; @@ -2422,6 +2450,36 @@ fn parse_style_ranges(value: Option<&Value>) -> Result 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() +} + /// Typed access to validated arguments. struct Args<'a>(&'a Value); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1bfcc77984..7785f84bc2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -344,6 +344,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 9049299702..ca7d6b19cd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -503,6 +503,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 4ebfecafc7..25bf76a944 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -362,6 +362,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/lib.rs b/crates/engine/src/lib.rs index aa71d82fef..12915bc3ce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,7 @@ pub mod signature_scan; mod stamp_preview; pub mod text_fonts; mod text_preview; +pub mod text_style_bookmarks; pub mod word_count; pub mod xfa; @@ -619,6 +620,12 @@ impl Document { self.generation } + /// The styles the document's text is set in, the largest first, for bookmarks from text + /// styles (#1693). + pub fn text_styles(&self) -> Vec { + self.editor.as_ref().map(|e| 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() @@ -1488,6 +1495,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 [`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, @@ -2015,6 +2027,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Colour", "Style", "New bookmarks from structure", + "New bookmarks from text styles", "Number pages", "Measure distance", "Measure perimeter", @@ -2162,6 +2175,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 { @@ -2334,6 +2348,7 @@ 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(()) @@ -2586,10 +2601,20 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> 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())); + } + let entries = 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/tests.rs b/crates/engine/src/tests.rs index dc76159d14..0458cac519 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1846,6 +1846,89 @@ 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()); +} + +/// `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..937752fa4c --- /dev/null +++ b/crates/engine/src/text_style_bookmarks.rs @@ -0,0 +1,211 @@ +//! 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). + 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 and left of its box in user space. + top: f64, + bottom: f64, + left: 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), + }) + }) + .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(); + for line in lines(doc) { + match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + 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(); + 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 and bottom of its last + // line. + let mut open: Option<(usize, 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)| { + let size = line.style.size; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + }) && 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)); + } + 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 cc1bbb0884..4e90c8eab6 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -509,14 +509,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]) @@ -560,7 +556,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. @@ -569,6 +565,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 @@ -592,7 +591,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 1ba8861cbe..fb9d03a7fe 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/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs new file mode 100644 index 0000000000..718e582f70 --- /dev/null +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -0,0 +1,127 @@ +//! 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) { + 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); + egui::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/commands.rs b/crates/ui-egui/src/commands.rs index c0e12d5d09..b10e16b8c8 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -219,6 +219,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), "edit.undo" => self.undo(), "edit.redo" => self.redo(), "edit.find" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 086dd2346c..f4100f2f05 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1129,6 +1129,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { close = apply || cancel; return; } + Dialog::BookmarksFromTextStyles => { + close = crate::bookmark_styles_ui::body(ui, app, &t); + return; + } Dialog::NumberPages => { use pdfcraft_engine::LabelStyle as L; ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4f25d6df21..e23123c3bd 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1228,6 +1228,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 e50145f8c9..ec2b596edb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,7 @@ pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep}; mod autoscroll; mod bidi; +pub mod bookmark_styles_ui; pub mod bulk_fields; mod dialogs; mod edit_text_ui; @@ -295,6 +296,8 @@ pub enum Dialog { NumberPages, /// 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. @@ -937,6 +940,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. @@ -1256,6 +1261,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/panels.rs b/crates/ui-egui/src/panels.rs index a7820c2d02..61975b275b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -734,6 +734,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")), @@ -1048,6 +1052,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/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151bb..1019014f64 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 2c9cf17197..6f87c071e8 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" From 4c0974777323afcf18dc3208aafbdb94121f2f41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:51:53 +0000 Subject: [PATCH 2/6] 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 Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM --- crates/engine/src/lib.rs | 16 +++++++- crates/engine/src/tests.rs | 46 +++++++++++++++++++++++ crates/engine/src/text_style_bookmarks.rs | 38 ++++++++++++++----- 3 files changed, 89 insertions(+), 11 deletions(-) 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 } From ee6f2a017f85e57d1645bf895ce306dc2a7b8ce0 Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:25:48 +0100 Subject: [PATCH 3/6] fix(ui): enforce copy permission before reading bookmark styles --- crates/ui-egui/src/bookmark_styles_ui.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index 718e582f70..dcdcfc80ef 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -36,6 +36,13 @@ impl PdfCraftApp { /// 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("Copying text and images is not permitted"); + 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] }; From 67ecccfc23bc78b09b68b6e52c6b5416168ca2ee Mon Sep 17 00:00:00 2001 From: Livio Oswald <125505497+Lcoding15@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:40:18 +0100 Subject: [PATCH 4/6] fix(ui): reuse translated copy-permission message --- crates/ui-egui/src/bookmark_styles_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/bookmark_styles_ui.rs b/crates/ui-egui/src/bookmark_styles_ui.rs index dcdcfc80ef..890a5f2efd 100644 --- a/crates/ui-egui/src/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/bookmark_styles_ui.rs @@ -40,7 +40,7 @@ impl PdfCraftApp { // 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("Copying text and images is not permitted"); + 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(); From cace97e7b2607d31e703bb04c17fa7975f40ec39 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 20:46:00 +0000 Subject: [PATCH 5/6] M4: one text style can't be given to two bookmark levels 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, left the later level empty without a word. The edit now refuses it, for the dialog and automation alike, naming the two levels (CodeRabbit on #4560). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/engine/src/edit/run.rs | 10 ++ crates/engine/src/tests.rs | 150 ++++++++++++---------- crates/engine/src/text_style_bookmarks.rs | 2 +- 3 files changed, 95 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 8734c372b7..703d1b2cc0 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -296,6 +296,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E 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())); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5dbcb7d81b..6439ba6363 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1927,6 +1927,24 @@ fn bookmarks_from_text_styles_nest_the_headings_and_undo() { 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] @@ -8849,69 +8867,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs index 07a0cacd78..5969188d67 100644 --- a/crates/engine/src/text_style_bookmarks.rs +++ b/crates/engine/src/text_style_bookmarks.rs @@ -24,7 +24,7 @@ pub struct TextStyle { 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). - fn matches(&self, other: &TextStyle) -> bool { + 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 } } From 7f33b11ba1bf6f0bb6e2eb10d1b39dca106d7eb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 22:27:27 +0000 Subject: [PATCH 6/6] M4: the text-style level lists are craft-ui combo boxes Main now refuses egui::ComboBox through clippy (#4988: its menu can't grow back while open, egui#5225), and the merge brought that rule in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GbuSKAS2k5248LGFEapMUR --- crates/ui-egui/src/dialogs/bookmark_styles_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs index 890a5f2efd..d022babb6f 100644 --- a/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs +++ b/crates/ui-egui/src/dialogs/bookmark_styles_ui.rs @@ -93,7 +93,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool 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); - egui::ComboBox::from_id_salt(("text_style_level", level)) + craft_ui::ComboBox::from_id_salt(("text_style_level", level)) .selected_text(shown) .width(460.0) .show_ui(ui, |ui| {