diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f3effea07..1dae412e4 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -383,6 +383,36 @@ impl Automation { let (path, page) = (a.path("path")?, self.page(&a)?); self.apply(&a, Edit::SetBookmarkPage { path, page })? } + "bookmark_set_style" => { + let path = a.path("path")?; + // Left out keeps the colour; [0, 0, 0] is the default black. + let color = match a.get("color") { + None => None, + Some(v) => { + let rgb: Option> = v.as_array().and_then(|c| c.iter().map(Value::as_f64).collect()); + match rgb.as_deref() { + Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]), + _ => return Err(ToolError::InvalidArgs("color must be [r, g, b], each from 0 to 1".into())), + } + } + }; + let (bold, italic) = (a.opt_bool("bold")?, a.opt_bool("italic")?); + if color.is_none() && bold.is_none() && italic.is_none() { + return Err(ToolError::InvalidArgs("give color, bold or italic".into())); + } + let mut edits = Vec::new(); + if let Some(color) = color { + edits.push(Edit::SetBookmarkColor { path: path.clone(), color: Some(color) }); + } + if bold.is_some() || italic.is_some() { + edits.push(Edit::SetBookmarkStyle { path, bold, italic }); + } + // A colour and a style given together are one step to undo, applied all or nothing. + match edits.len() { + 1 => self.apply(&a, edits.remove(0))?, + _ => self.apply(&a, Edit::Batch { label: "Style".into(), edits })?, + } + } "bookmark_from_structure" => { let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; let listing = self.bookmark_listing(&a)?; @@ -2445,6 +2475,16 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::Pa } let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + // Only what differs from the defaults (black, regular), as the PDF itself stores it. + if let Some(color) = row.color { + fields["color"] = json!(color); + } + if row.bold { + fields["bold"] = json!(true); + } + if row.italic { + fields["italic"] = json!(true); + } if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { fields["destination"] = destination; } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 33be4b71b..1e009c117 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -278,7 +278,7 @@ pub fn tools() -> Vec { t( "bookmark_list", "List bookmarks", - "The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.", + "The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, its colour ([r, g, b], 0-1) and bold or italic style when it has them, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.", ) .ro() .with(schema( @@ -317,6 +317,21 @@ pub fn tools() -> Vec { )), t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.") .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])), + t( + "bookmark_set_style", + "Set a bookmark's style and colour", + "Colour a bookmark's title and make it bold or italic (ISO 32000-2 §12.3.3: /C and /F), as bookmark_list reports them. Give any of color ([r, g, b], each 0-1; [0, 0, 0] is the default black), bold and italic; what is left out stays as it is. Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "path": path("The bookmark"), + "color": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 1 }, "minItems": 3, "maxItems": 3 }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" } + }), + &["doc", "path"], + )), t( "bookmark_from_structure", "New bookmarks from structure", diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index b6b72d077..9fd5c4269 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8343,3 +8343,84 @@ fn comments_are_reshaped_through_the_tool() { assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends"); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_)))); } + +/// #3193: bookmark_list reports a bookmark's colour and style (ISO 32000-2 §12.3.3, Tables 151 +/// and 152), only where they differ from black and regular. The list is read from the opened file +/// and, once the document has been edited, from the edited one; both read them alike. +/// bookmark_set_style changes them, and they survive a save. +#[test] +fn bookmark_list_reports_colour_and_style() { + let dir = workdir("bookmark-style"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", + b"<< /Type /Page /Parent 2 0 R >>", + b"<< /Type /Outlines /First 5 0 R /Last 7 0 R /Count 3 >>", + b"<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>", + b"<< /Title (Red bold) /Parent 4 0 R /Prev 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] /C [1 0 0] /F 2 >>", + b"<< /Title (Blue italic) /Parent 4 0 R /Prev 6 0 R /Dest [3 0 R /Fit] /C [0 0 1] /F 1 >>", + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("styled.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap(); + let style = |list: &Value| -> Vec<(Value, Value, Value)> { + list.as_array().unwrap().iter().map(|b| (b["color"].clone(), b["bold"].clone(), b["italic"].clone())).collect() + }; + let expected = vec![ + (Value::Null, Value::Null, Value::Null), + (json!([1.0, 0.0, 0.0]), json!(true), Value::Null), + (json!([0.0, 0.0, 1.0]), Value::Null, json!(true)), + ]; + let opened = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&opened), expected, "as opened: {opened}"); + ok(&mut a, "bookmark_rename", json!({ "doc": doc, "path": [1], "title": "Still plain" })); + let edited = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&edited), expected, "after an edit: {edited}"); + + // bookmark_set_style changes only what it is given. + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [1], "color": [0, 0.5, 0], "italic": true })); + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "bold": false })); + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [3], "color": [0, 0, 0] })); + let set = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!( + style(&set), + vec![ + (json!([0.0, 0.5, 0.0]), Value::Null, json!(true)), + (json!([1.0, 0.0, 0.0]), Value::Null, Value::Null), + (Value::Null, Value::Null, json!(true)) + ], + "{set}" + ); + // A colour and a style in one call are one undo step. + ok(&mut a, "bookmark_set_style", json!({ "doc": doc, "path": [2], "color": [0, 0, 0], "bold": true, "italic": true })); + let both = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&both)[1], (Value::Null, json!(true), json!(true)), "{both}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undone = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(style(&undone), style(&set), "one undo takes back both: {undone}"); + for bad in [ + json!({ "doc": doc, "path": [1] }), + json!({ "doc": doc, "path": [1], "color": [2, 0, 0] }), + json!({ "doc": doc, "path": [1], "color": [1, 0] }), + ] { + assert!(matches!(a.call("bookmark_set_style", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "restyled.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 92c1ba1ad..68fc076b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1129,6 +1129,10 @@ pub struct BookmarkRow { pub page: Option, /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, } /// One page of the bookmark list, in outline order. @@ -1156,7 +1160,16 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { top.1 += 1; } let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view }); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); if !item.children.is_empty() { levels.push((&item.children, 0)); } @@ -1416,6 +1429,17 @@ pub enum Edit { path: Vec, page: usize, }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first /// "Untitled" bookmark (New Bookmarks from Structure). BookmarksFromStructure, @@ -1919,6 +1943,8 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete bookmark", "Move bookmark", "Set bookmark destination", + "Colour", + "Style", "New bookmarks from structure", "Number pages", "Measure distance", @@ -2031,7 +2057,7 @@ pub const EDIT_LABELS: &[&str] = &[ /// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives /// already worded in the interface's language. pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL]; + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; impl Edit { /// Label for the Edit menu and history ("Undo Rotate pages"). @@ -2062,6 +2088,9 @@ impl Edit { Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), Edit::MoveBookmark { .. } => Label::of("Move bookmark"), Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), Edit::NumberPages { .. } => Label::of("Number pages"), // What was measured is part of the wording too, for the same reason. @@ -2229,6 +2258,8 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::DeleteBookmark { .. } | Edit::MoveBookmark { .. } | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure | Edit::NumberPages { .. } => { if p.assemble() { @@ -2474,6 +2505,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; } Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, Edit::BookmarksFromStructure => { let entries: Vec<_> = pdfcraft_a11y::headings(doc) .into_iter() @@ -4858,7 +4891,7 @@ impl Session { let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view } + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } }) .collect(); return Some(BookmarkPage { rows, next: page.next, truncated: false }); diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index d0e088fd7..9b731a1f2 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -34,7 +34,7 @@ pub use import::{ pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, - move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks, + move_bookmark, rename_bookmark, set_bookmark_color, set_bookmark_open, set_bookmark_page, set_bookmark_style, top_level_bookmarks, }; pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition}; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 39e601d73..4bcfe93ae 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -41,6 +41,13 @@ pub struct Bookmark { /// Where on that page it goes: the destination's type (`XYZ`, `Fit`, `FitH`…) and the numbers /// after it, in PDF user space (`None` for `null` or anything that isn't a number). pub view: Option<(Vec, Vec>)>, + /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), each component kept + /// within 0–1; `None` for the default black or a value that isn't three numbers. The + /// inspector's `OutlineItem::color` reads it the same way. + pub color: Option<[f32; 3]>, + /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. + pub italic: bool, + pub bold: bool, } /// One page of the outline listing (see [`bookmark_page`]). @@ -61,6 +68,8 @@ pub enum OutlineError { EmptyTitle, #[error("a bookmark's position must be a finite number")] BadPosition, + #[error("a bookmark's colour must be three numbers from 0 to 1")] + BadColor, #[error("this document has no tagged headings to make bookmarks from")] NoEntries, #[error("{0}")] @@ -306,7 +315,19 @@ fn describe(doc: &Document, targets: &mut Targets, obj: ObjRef, path: Vec }; let page = dest.as_deref().and_then(|d| page_of_explicit(targets, d)); let view = dest.as_deref().filter(|_| page.is_some()).and_then(|d| view_of_explicit(doc, d)); - Bookmark { obj, path, title, open, page, view } + let color = item.as_dict().and_then(|d| d.get(b"C").map(|c| doc.resolve(c))).and_then(|c| title_color(doc, &c)); + let flags = item.as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_int())).unwrap_or(0); + Bookmark { obj, path, title, open, page, view, color, italic: flags & 1 != 0, bold: flags & 2 != 0 } +} + +/// A bookmark title's `/C` as a colour: three numbers, each kept within 0–1 (ISO 32000-2 +/// §12.3.3, Table 151: "An array of three numbers in the range 0.0 to 1.0"); a number past the +/// range, however large, is kept at its edge. Black, the default, and anything else give `None`. +fn title_color(doc: &Document, c: &Object) -> Option<[f32; 3]> { + let [r, g, b] = c.as_array()?.as_slice() else { return None }; + let channel = |v: &Object| doc.resolve(v).as_f64().map(|v| v.clamp(0.0, 1.0) as f32); + let rgb = [channel(r)?, channel(g)?, channel(b)?]; + (rgb != [0.0; 3]).then_some(rgb) } /// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`. @@ -612,6 +633,33 @@ pub fn rename_bookmark(doc: &mut Document, path: &[usize], title: &str) -> Resul put(doc, r, b"Title", Some(Object::String(PdfString::text(title)))) } +/// Set the colour of a bookmark's title (`/C`, ISO 32000-2 §12.3.3, Table 151: "An array of three +/// numbers in the range 0.0 to 1.0, representing the components in the DeviceRGB colour space"). +/// Black, the default, and `None` remove `/C`; a component outside 0–1 is refused. +pub fn set_bookmark_color(doc: &mut Document, path: &[usize], color: Option<[f64; 3]>) -> Result<()> { + if color.is_some_and(|c| !c.iter().all(|v| (0.0..=1.0).contains(v))) { + return Err(OutlineError::BadColor); + } + let (r, _) = resolve_path(doc, path)?; + let value = color.filter(|c| *c != [0.0; 3]).map(|c| Object::Array(c.iter().map(|v| Object::Real(*v)).collect())); + put(doc, r, b"C", value) +} + +/// Make a bookmark's title bold or italic or not (`/F`, ISO 32000-2 §12.3.3, Table 152: bit 1 +/// italic, bit 2 bold); `None` keeps that one as it is, and so are the other bits. When no bit is +/// left, `/F` is removed, its default being 0. +pub fn set_bookmark_style(doc: &mut Document, path: &[usize], bold: Option, italic: Option) -> Result<()> { + let (r, _) = resolve_path(doc, path)?; + let old = doc.get(r).as_dict().and_then(|d| d.get(b"F").and_then(|f| doc.resolve(f).as_int())).unwrap_or(0); + let bit = |flags: i64, mask: i64, on: Option| match on { + Some(true) => flags | mask, + Some(false) => flags & !mask, + None => flags, + }; + let flags = bit(bit(old, 1, italic), 2, bold); + put(doc, r, b"F", (flags != 0).then_some(Object::Int(flags))) +} + /// Point a bookmark at `page` (0-based), replacing its destination or GoTo action. pub fn set_bookmark_page(doc: &mut Document, path: &[usize], page: usize) -> Result<()> { let dest = destination(doc, page)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9ce128432..f0bb3738d 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3286,3 +3286,84 @@ fn repeated_content_references_share_one_decode_budget() { assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes"); } } + +/// #3193: the bookmark list reads a bookmark's colour and style as the inspector does (ISO +/// 32000-2 §12.3.3, Tables 151 and 152): `/C` within 0–1, black and malformed values as none, +/// `/F` bit 1 italic and bit 2 bold, through indirect values too. +#[test] +fn bookmarks_report_their_colour_and_style() { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 400] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 10 0 R /Count 6 >>".into(), + ]; + objs.push("<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into()); // 5 + objs.push("<< /Title (Red bold) /Parent 4 0 R /Prev 5 0 R /Next 7 0 R /Dest [3 0 R /Fit] /C [1 0 0] /F 2 >>".into()); // 6 + objs.push("<< /Title (Indirect) /Parent 4 0 R /Prev 6 0 R /Next 8 0 R /Dest [3 0 R /Fit] /C 11 0 R /F 12 0 R >>".into()); // 7 + objs.push("<< /Title (Black) /Parent 4 0 R /Prev 7 0 R /Next 9 0 R /Dest [3 0 R /Fit] /C [0 0 0] /F 1 >>".into()); // 8 + objs.push("<< /Title (Bright) /Parent 4 0 R /Prev 8 0 R /Next 10 0 R /Dest [3 0 R /Fit] /C [2 -1 0.5] >>".into()); // 9 + objs.push("<< /Title (Short) /Parent 4 0 R /Prev 9 0 R /Dest [3 0 R /Fit] /C [1 0] /F 4 >>".into()); // 10 + objs.push("[0 0.5 0]".into()); // 11 + objs.push("3".into()); // 12 + let doc = open(build(&objs, "/Root 1 0 R")); + let got: Vec<(String, Option<[f32; 3]>, bool, bool)> = bookmarks(&doc).into_iter().map(|b| (b.title, b.color, b.italic, b.bold)).collect(); + assert_eq!( + got, + [ + ("Plain".into(), None, false, false), + ("Red bold".into(), Some([1.0, 0.0, 0.0]), false, true), + ("Indirect".into(), Some([0.0, 0.5, 0.0]), true, true), + ("Black".into(), None, true, false), + ("Bright".into(), Some([1.0, 0.0, 0.5]), false, false), + ("Short".into(), None, false, false), + ] + ); +} + +/// #3193: setting a bookmark's style and colour writes what ISO 32000-2 §12.3.3 asks for: bold is +/// `/F 2` and bold italic `/F 3` (Table 152), and neither removes `/F`, whose default is 0; a +/// style change leaves `/C` alone and a colour change leaves `/F`; black, the default (Table 151), +/// removes `/C`. Bits the table doesn't define are kept, and a colour outside 0–1 is refused. +#[test] +fn bookmark_style_and_colour_are_set_as_the_table_says() { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 400] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 6 0 R /Count 2 >>".into(), + ]; + objs.push("<< /Title (Plain) /Parent 4 0 R /Next 6 0 R /Dest [3 0 R /Fit] >>".into()); // 5 + objs.push("<< /Title (Odd bits) /Parent 4 0 R /Prev 5 0 R /Dest [3 0 R /Fit] /F 4 /C [1 0 0] >>".into()); // 6 + let mut doc = open(build(&objs, "/Root 1 0 R")); + let raw = |doc: &Document, n: u32, key: &[u8]| doc.get(ObjRef { num: n, generation: 0 }).as_dict().and_then(|d| d.get(key).cloned()); + crate::set_bookmark_style(&mut doc, &[0], Some(true), None).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(2)), "bold"); + crate::set_bookmark_style(&mut doc, &[0], None, Some(true)).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(3)), "bold italic"); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0, 0.5, 0.0])).unwrap(); + assert_eq!(raw(&doc, 5, b"C"), Some(Object::Array(vec![Object::Real(0.0), Object::Real(0.5), Object::Real(0.0)]))); + assert_eq!(raw(&doc, 5, b"F"), Some(Object::Int(3)), "a colour change keeps the style"); + crate::set_bookmark_style(&mut doc, &[0], Some(false), Some(false)).unwrap(); + assert_eq!(raw(&doc, 5, b"F"), None, "regular removes /F"); + assert!(raw(&doc, 5, b"C").is_some(), "a style change keeps the colour"); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0; 3])).unwrap(); + assert_eq!(raw(&doc, 5, b"C"), None, "black removes /C"); + + crate::set_bookmark_style(&mut doc, &[1], Some(true), None).unwrap(); + assert_eq!(raw(&doc, 6, b"F"), Some(Object::Int(6)), "bold beside a bit the table doesn't define"); + crate::set_bookmark_style(&mut doc, &[1], Some(false), Some(false)).unwrap(); + assert_eq!(raw(&doc, 6, b"F"), Some(Object::Int(4)), "that bit stays"); + crate::set_bookmark_color(&mut doc, &[1], None).unwrap(); + assert_eq!(raw(&doc, 6, b"C"), None, "no colour is black"); + for bad in [[1.5, 0.0, 0.0], [0.0, -0.1, 0.0], [f64::NAN, 0.0, 0.0]] { + assert_eq!(crate::set_bookmark_color(&mut doc, &[1], Some(bad)), Err(crate::OutlineError::BadColor), "{bad:?}"); + } + assert_eq!(crate::set_bookmark_style(&mut doc, &[7], Some(true), None), Err(crate::OutlineError::NoSuchBookmark(vec![7]))); + + crate::set_bookmark_style(&mut doc, &[0], Some(true), Some(true)).unwrap(); + crate::set_bookmark_color(&mut doc, &[0], Some([0.0, 0.0, 1.0])).unwrap(); + let saved = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect(); + assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index c47d04cdc..81fad6b0b 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -402,6 +402,12 @@ pub struct OutlineItem { pub view: DestView, pub children: Vec, pub open: bool, + /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the + /// default black or a value that isn't three numbers. + pub color: Option<[f32; 3]>, + /// The title's style (`/F`, Table 152): bit 1 italic, bit 2 bold. + pub italic: bool, + pub bold: bool, } #[derive(Clone, Debug)] @@ -1075,7 +1081,13 @@ impl<'a> Inspector<'a> { } let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); let title = self.text(d, b"Title").unwrap_or_default(); - listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open })); + let color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) => outline_color(c.iter().map(|v| self.resolve(v).as_float().ok())), + _ => None, + }; + let flags = d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0); + let (italic, bold) = (flags & 1 != 0, flags & 2 != 0); + listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open, color, italic, bold })); } (nest_outline(listed), more) } @@ -1813,6 +1825,18 @@ fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option { (text.len() <= max_bytes).then_some(text) } +/// A bookmark title's colour from its `/C` entries, each kept within 0–1 (ISO 32000-2 §12.3.3, +/// Table 151: "An array of three numbers in the range 0.0 to 1.0, representing the components in +/// the DeviceRGB colour space"). Black, the default, and anything that isn't three numbers give +/// `None`. +pub(crate) fn outline_color(values: impl Iterator>) -> Option<[f32; 3]> { + let v: Vec> = values.take(4).collect(); + let [Some(r), Some(g), Some(b)] = v.as_slice() else { return None }; + // A number past the range, however large, is kept at its edge. + let rgb = [*r, *g, *b].map(|c| c.clamp(0.0, 1.0)); + (rgb != [0.0; 3]).then_some(rgb) +} + /// Nests a pre-order list of `(depth, item)` into the outline tree. fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { let mut roots = Vec::new(); @@ -3513,6 +3537,54 @@ trailer << /Root 1 0 R >> items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect() } + /// #3193: a bookmark's colour and style (ISO 32000-2 §12.3.3, Tables 151 and 152). `/C` is + /// three numbers within 0–1 (one outside is kept at the edge); black, the default, and a `/C` + /// that isn't three numbers read as no colour. `/F` bit 1 is italic, bit 2 bold. + #[test] + fn outline_items_carry_their_colour_and_style() { + let titles = [ + ("Plain", ""), + ("Red", "/C [1 0 0]"), + ("Italic", "/F 1"), + ("Bold", "/F 2"), + ("Both", "/C [0 0 1] /F 3"), + ("Black", "/C [0 0 0]"), + ("Bright", "/C [2 -1 0.5]"), + ("Short", "/C [1 0]"), + ("Long", "/C [1 0 0 0]"), + ("Named", "/C [1 /x 0]"), + ("Other bits", "/F 4"), + ]; + let n = titles.len(); + let items: Vec = titles + .iter() + .enumerate() + .map(|(k, (title, extra))| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title ({title}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] {extra} >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let got: Vec<(&str, Option<[f32; 3]>, bool, bool)> = info.outline.iter().map(|o| (o.title.as_str(), o.color, o.italic, o.bold)).collect(); + assert_eq!( + got, + [ + ("Plain", None, false, false), + ("Red", Some([1.0, 0.0, 0.0]), false, false), + ("Italic", None, true, false), + ("Bold", None, false, true), + ("Both", Some([0.0, 0.0, 1.0]), true, true), + ("Black", None, false, false), + ("Bright", Some([1.0, 0.0, 0.5]), false, false), + ("Short", None, false, false), + ("Long", None, false, false), + ("Named", None, false, false), + ("Other bits", None, false, false), + ] + ); + } + #[test] fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { const COUNT: usize = OUTLINE_WINDOW + 5; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 5b821b638..4561d592d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1107,6 +1107,8 @@ impl PdfCraftApp { } A::Rename(path, title) => Edit::RenameBookmark { path, title }, A::SetToCurrentPage(path) => Edit::SetBookmarkPage { path, page: current }, + A::SetStyle(path, bold, italic) => Edit::SetBookmarkStyle { path, bold, italic }, + A::SetColor(path, color) => Edit::SetBookmarkColor { path, color: Some(color) }, A::Delete(path) => Edit::DeleteBookmark { path }, A::MoveUp(path) => { let at = path[path.len() - 1].saturating_sub(1); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ef65e957f..7d16bc340 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1021,6 +1021,10 @@ pub enum BmAction { StartRename(Vec), Rename(Vec, String), SetToCurrentPage(Vec), + /// Make the title bold or italic or not (`None` keeps that one). + SetStyle(Vec, Option, Option), + /// Colour the title; black is the default. + SetColor(Vec, [f64; 3]), Delete(Vec), MoveUp(Vec), MoveDown(Vec), @@ -1094,6 +1098,23 @@ pub(crate) fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) } } +/// How a bookmark's title is drawn: in its own style and colour (ISO 32000-2 §12.3.3, Tables 151 +/// and 152), bold in the heavier face and italic slanted, its colour kept readable on the panel. +/// Top-level bookmarks without a style keep the medium face. +fn title_format(item: &OutlineItem, depth: usize, t: &Tokens) -> egui::TextFormat { + let font_id = match (item.bold, depth) { + (true, _) => theme::semibold(13.0), + (false, 0) => theme::medium(13.0), + (false, _) => theme::regular(13.0), + }; + let color = item.color.map_or(t.text, |rgb| { + // Each component is within 0–1, so the rounded value fits a byte. + let [r, g, b] = rgb.map(|c| (c * 255.0).round() as u8); + theme::readable(egui::Color32::from_rgb(r, g, b), t.panel, t.text) + }); + egui::TextFormat { font_id, color, italics: item.italic, ..Default::default() } +} + fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { if cx.searching && !cx.matches.contains(path) { return; @@ -1147,9 +1168,10 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte }) }); let label_w = label_galley.as_ref().map_or(0.0, |g| g.size().x); - let font = if depth == 0 { theme::medium(13.0) } else { theme::regular(13.0) }; let wrap_w = (ui.available_width() - indent - 20.0 - label_w - 12.0).max(60.0); - let galley = ui.fonts_mut(|f| f.layout(item.title.clone(), font, t.text, wrap_w)); + let mut job = egui::text::LayoutJob::single_section(item.title.clone(), title_format(item, depth, t)); + job.wrap.max_width = wrap_w; + let galley = ui.fonts_mut(|f| f.layout_job(job)); let h = (galley.size().y + 12.0).max(28.0); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title)); @@ -1192,6 +1214,26 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte pick(ui, tl!("Rename"), true, BmAction::StartRename(path.to_vec())); let set_page = crate::i18n::fmt(tl!("Set to current page ({n})"), &[("n", &(current + 1).to_string())]); pick(ui, &set_page, true, BmAction::SetToCurrentPage(path.to_vec())); + // The title's style and colour (ISO 32000-2 §12.3.3, Tables 151 and 152). + let mut styled = None; + ui.menu_button(tl!("Style"), |ui| { + let (mut bold, mut italic) = (item.bold, item.italic); + if ui.add(crate::widgets::checkbox(&mut bold, tl!("Bold"))).clicked() { + styled = Some(BmAction::SetStyle(path.to_vec(), Some(bold), None)); + } + if ui.add(crate::widgets::checkbox(&mut italic, tl!("Italic"))).clicked() { + styled = Some(BmAction::SetStyle(path.to_vec(), None, Some(italic))); + } + }); + ui.menu_button(tl!("Colour"), |ui| { + let current = item.color.map_or([0.0; 3], |c| c.map(f64::from)); + if let Some(c) = crate::comments::swatch_grid(ui, Some(current)) { + styled = Some(BmAction::SetColor(path.to_vec(), c)); + } + }); + if styled.is_some() { + ui.close(); + } ui.separator(); pick(ui, tl!("Move up"), i > 0, BmAction::MoveUp(path.to_vec())); pick(ui, tl!("Move down"), i + 1 < siblings, BmAction::MoveDown(path.to_vec())); @@ -1199,6 +1241,9 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte pick(ui, tl!("Outdent"), depth > 0, BmAction::Outdent(path.to_vec())); ui.separator(); pick(ui, tl!("Delete"), true, BmAction::Delete(path.to_vec())); + if let Some(a) = styled { + *cx.action = Some(a); + } }); } } @@ -1865,11 +1910,39 @@ mod label_column { #[cfg(test)] mod bookmark_filter { - use super::outline_matches; + use super::{outline_matches, title_format}; + use crate::theme::{self, Tokens}; use pdfcraft_render::OutlineItem; + /// #3193: a bookmark's title is drawn in its own style and colour. + #[test] + fn bookmark_titles_take_their_style_and_colour() { + let t = Tokens::for_kind(crate::theme::ThemeKind::Light); + let plain = item("Plain", Vec::new()); + assert_eq!(title_format(&plain, 0, &t).font_id, theme::medium(13.0)); + assert_eq!(title_format(&plain, 1, &t).font_id, theme::regular(13.0)); + assert_eq!(title_format(&plain, 1, &t).color, t.text); + assert!(!title_format(&plain, 1, &t).italics); + let styled = OutlineItem { color: Some([1.0, 0.0, 0.0]), italic: true, bold: true, ..item("Styled", Vec::new()) }; + let f = title_format(&styled, 1, &t); + assert_eq!(f.font_id, theme::semibold(13.0)); + assert!(f.italics); + assert_eq!(f.color, egui::Color32::from_rgb(255, 0, 0)); + let half = OutlineItem { color: Some([0.0, 0.5, 0.0]), ..item("Green", Vec::new()) }; + assert_eq!(title_format(&half, 1, &t).color, egui::Color32::from_rgb(0, 128, 0)); + } + fn item(title: &str, children: Vec) -> OutlineItem { - OutlineItem { title: title.into(), page: Some(0), view: pdfcraft_render::DestView::Top, children, open: true } + OutlineItem { + title: title.into(), + page: Some(0), + view: pdfcraft_render::DestView::Top, + children, + open: true, + color: None, + italic: false, + bold: false, + } } /// Turkish dotted and dotless i match in any case, as in Find (#892). diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2cd232425..ea76a6991 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -494,9 +494,48 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { ctx.set_style_of(egui::Theme::Dark, style); } +/// WCAG 2 contrast ratio between two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let lin = |v: u8| { + let s = v as f32 / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) + }; + let (x, y) = (lum(a) + 0.05, lum(b) + 0.05); + x.max(y) / x.min(y) +} + +/// `color` as text on `background`: itself when it reads (a WCAG 2 contrast of at least 3:1, the +/// level for large or bold text), otherwise moved towards `text` until it does. A document's own +/// colours (a bookmark's `/C`) were chosen for a white panel, and dark blue on a dark theme would +/// be lost. +pub fn readable(color: Color32, background: Color32, text: Color32) -> Color32 { + (0..=4).map(|step| color.lerp_to_gamma(text, step as f32 / 4.0)).find(|c| contrast(*c, background) >= 3.0).unwrap_or(text) +} + #[cfg(test)] mod tests { use super::*; + + /// #3193: a bookmark's own colour is kept where it reads on the panel, and moved towards the + /// theme's text colour where it doesn't (dark blue on the dark panel), never past it. + #[test] + fn a_document_colour_is_kept_where_it_reads() { + let (light, dark) = (Tokens::for_kind(ThemeKind::Light), Tokens::for_kind(ThemeKind::Dark)); + let red = Color32::from_rgb(255, 0, 0); + assert_eq!(readable(red, light.panel, light.text), red, "red reads on white"); + let navy = Color32::from_rgb(0, 0, 128); + assert_eq!(readable(navy, light.panel, light.text), navy); + let lifted = readable(navy, dark.panel, dark.text); + assert_ne!(lifted, navy, "navy is lost on the dark panel"); + assert!(contrast(lifted, dark.panel) >= 3.0, "{lifted:?}"); + assert_ne!(lifted, dark.text, "only as far as it needs"); + // Even the panel's own colour comes out readable. + assert!(contrast(readable(dark.panel, dark.panel, dark.text), dark.panel) >= 3.0); + assert!(contrast(readable(light.panel, light.panel, light.text), light.panel) >= 3.0); + } use egui::epaint::text::{Fonts, TextOptions}; const HINTING_CORPUS: [&str; 4] = ["Il1MWmw", "Hamburgefonts", "0123456789 .,;:!?()[]{}+-=/\\", "Café Ångström déjà-vu Ωμέγα Ελληνικά Журнал"]; @@ -730,19 +769,6 @@ mod tests { assert_eq!(ThemePreference::Dark.resolve(Some(false), ThemeKind::Light), ThemeKind::Dark); } - /// WCAG 2 contrast ratio between two opaque colours. - fn contrast(a: Color32, b: Color32) -> f32 { - let lum = |c: Color32| { - let lin = |v: u8| { - let s = v as f32 / 255.0; - if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } - }; - 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) - }; - let (x, y) = (lum(a) + 0.05, lum(b) + 0.05); - x.max(y) / x.min(y) - } - #[test] fn press_darkens_then_sinks() { let rest = Color32::from_rgb(0xFF, 0xFF, 0xFF); diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 443359988..1cf78958f 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -274,3 +274,39 @@ fn expanding_and_collapsing_all_bookmarks() { h.run_steps(3); assert!(shown(&h, "Chapter") && !shown(&h, "Section")); } + +/// #3193: a bookmark's Style and Colour menus make its title bold, italic and coloured +/// (ISO 32000-2 §12.3.3, Tables 151 and 152), each an undoable edit. +#[test] +fn style_and_colour_from_the_bookmark_menu() { + let mut h = harness(); + add(&mut h, "Intro"); + let look = |h: &Harness<'static, PdfCraftApp>| { + let id = h.state().views[0].id; + let b = &h.state().session.get(id).unwrap().info.outline[0]; + (b.bold, b.italic, b.color) + }; + let submenu = |h: &mut Harness<'static, PdfCraftApp>, menu: &str, item: &str| { + h.get_by_label("Intro").click_secondary(); + h.run_steps(2); + // A submenu's button carries its arrow. + h.get_by_label(&format!("{menu} ⏵")).click(); + h.run_steps(2); + h.get_by_label(item).click(); + h.run_steps(3); + }; + submenu(&mut h, "Style", "Bold"); + assert_eq!(look(&h), (true, false, None)); + submenu(&mut h, "Style", "Italic"); + assert_eq!(look(&h), (true, true, None)); + submenu(&mut h, "Colour", "Red"); + let (bold, italic, color) = look(&h); + assert!(bold && italic, "the colour keeps the style"); + let [r, g, b] = color.expect("coloured"); + assert!((r - 0.89).abs() < 0.01 && (g - 0.13).abs() < 0.01 && (b - 0.13).abs() < 0.01, "{color:?}"); + submenu(&mut h, "Style", "Bold"); + assert!(!look(&h).0, "bold again turns it off"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(look(&h).0, "undoable"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 233afd105..2f04d928f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3313,11 +3313,11 @@ title = "Bookmark style and colour" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["bookmark_list", "bookmark_set_style"] +evidence = ["crates/render/src/inspect.rs::outline_items_carry_their_colour_and_style", "crates/organize/src/tests.rs::bookmarks_report_their_colour_and_style", "crates/organize/src/tests.rs::bookmark_style_and_colour_are_set_as_the_table_says", "crates/ui-egui/src/panels.rs::bookmark_titles_take_their_style_and_colour", "crates/ui-egui/src/theme.rs::a_document_colour_is_kept_where_it_reads", "crates/ui-egui/tests/it/bookmarks.rs::style_and_colour_from_the_bookmark_menu", "crates/automation/tests/it/automation.rs::bookmark_list_reports_colour_and_style"] +notes = "#3193: the Bookmarks panel draws each title in its /C colour and /F style (ISO 32000-2 §12.3.3, Tables 151 and 152: bit 1 italic, bit 2 bold), lightening a colour too dark for the dark theme until it reads (3:1). A bookmark's menu has Style (Bold, Italic) and Colour (the comment swatches and Other colour…); bookmark_set_style does the same and bookmark_list reports them. Regular removes /F, black removes /C, and /F bits the table doesn't define are kept. Not yet: one change for several selected bookmarks, and a Properties dialog." [[feature]] id = "organize.bookmark-actions"