Merge pull request #4560 from Lcoding15/feat/bookmarks-from-text-styles

M4: New bookmarks from text styles, for headings that aren't tagged (#1693)
This commit is contained in:
aiongg
2026-10-28 17:50:47 -04:00
committed by GitHub
26 changed files with 862 additions and 12 deletions
+29 -1
View File
@@ -1,5 +1,5 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
use super::paths::{read_input, write_atomic};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
@@ -234,6 +234,34 @@ impl Automation {
out["next"] = listing["next"].clone();
out
}
"text_styles" => {
// The samples are the document's text, as text_lines is.
let doc = self.doc_to_copy(&a)?;
let styles: Vec<Value> = 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")?;
+30
View File
@@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
// ---- JSON helpers ----------------------------------------------------------------------------
/// The most bookmark levels `bookmark_from_text_styles` takes.
const MAX_STYLE_LEVELS: usize = 9;
/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each
/// `{ font, size, bold, italic }` as text_styles lists them.
pub(crate) fn text_style_levels(a: &Args) -> Result<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
let levels =
a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?;
if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS {
return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles")));
}
levels
.iter()
.enumerate()
.map(|(i, v)| {
let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}"));
let font =
v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?;
let size =
v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?;
let flag = |key: &str| match v.get(key) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(_) => Err(bad(&format!("{key} must be true or false"))),
};
Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? })
})
.collect()
}
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
let Some(value) = value else { return Ok(Vec::new()) };
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
+33
View File
@@ -347,6 +347,39 @@ pub fn tools() -> Vec<ToolDef> {
"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(),
+72
View File
@@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() {
assert_eq!(result["status"], "passed");
}
/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18,
/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693).
fn styled_headings_pdf() -> Vec<u8> {
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<String> = 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<Value> = (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<u8> {
let objs: Vec<String> = vec![
+2
View File
@@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
+10
View File
@@ -237,6 +237,16 @@ impl Document {
self.generation
}
/// The styles the document's text is set in, the largest first, for bookmarks from text
/// styles (#1693).
/// Empty when the document doesn't allow copying: the samples are its text.
pub fn text_styles(&self) -> Vec<crate::text_style_bookmarks::TextStyleUse> {
if !self.allows_copying() {
return Vec::new();
}
self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default()
}
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
+5
View File
@@ -122,6 +122,11 @@ pub enum Edit {
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
/// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests
/// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]).
BookmarksFromTextStyles {
levels: Vec<crate::text_style_bookmarks::TextStyle>,
},
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
NumberPages {
from: usize,
+2
View File
@@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Colour",
"Style",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
"Measure distance",
"Measure perimeter",
@@ -186,6 +187,7 @@ impl Edit {
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
// What was measured is part of the wording too, for the same reason.
Edit::AddMeasurement(m) => Label::of(match m.kind {
+32 -1
View File
@@ -32,6 +32,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
Err(EditError::NotPermitted("page changes"))
}
}
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
// (Table 22, bit 5), as an export of it would be.
Edit::BookmarksFromTextStyles { .. } => {
if !p.assemble() {
Err(EditError::NotPermitted("page changes"))
} else if !p.copy() {
Err(EditError::NotPermitted("copying text and images"))
} else {
Ok(())
}
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::AddCustomStamp { .. }
@@ -280,10 +291,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
.collect();
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::BookmarksFromTextStyles { levels } => {
if levels.is_empty() {
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
}
// Each line goes to the first level whose style it is in: a style given again, or one
// within the size tolerance of an earlier level's, would leave its level empty.
for (i, a) in levels.iter().enumerate() {
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
return Err(EditError::Invalid(format!(
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
)));
}
}
let entries = crate::text_style_bookmarks::headings(doc, levels);
if entries.is_empty() {
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
}
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
}
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
Edit::AddMeasurement(m) => {
measure::add(doc, m, &cx.meta())?;
+1
View File
@@ -54,6 +54,7 @@ mod stamp_preview;
mod test_hooks;
pub mod text_fonts;
mod text_preview;
pub mod text_style_bookmarks;
pub mod word_count;
pub mod xfa;
mod xfa_form;
+147
View File
@@ -1849,6 +1849,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() {
assert!(s.get(id).unwrap().info.outline.is_empty());
}
/// Three pages whose headings are set apart only by how they look (#1693): chapters in
/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title
/// runs over two lines, its bold font is another subset of the same font, and one section is set
/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row.
fn styled_headings_fixture() -> Vec<u8> {
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(&section, 30.0);
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err();
assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style
/// for two levels (or one within the size tolerance of the other) would leave the later level
/// empty. Refused at the edit, for the dialog and automation alike; nothing changes.
#[test]
fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
let styles = s.get(id).unwrap().text_styles();
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
let near = pdfcraft_engine_style(&section, section.size + 0.1);
for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] {
let n = levels.len();
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err();
assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}");
assert!(s.get(id).unwrap().info.outline.is_empty());
}
}
/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document
/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction.
#[test]
fn bookmarks_from_text_styles_need_copying_permission() {
let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying());
assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied");
let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false };
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err();
assert_eq!(err, EditError::NotPermitted("copying text and images"));
let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(!s.get(owner).unwrap().text_styles().is_empty());
assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may");
}
/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower,
/// are two bookmarks, not one heading run on across the columns.
#[test]
fn headings_in_two_columns_are_two_bookmarks() {
let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET";
let bytes = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
.stream("", page)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
.build();
let mut s = Session::new();
let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap();
let style = s.get(id).unwrap().text_styles()[0].style.clone();
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap();
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]);
}
/// `style` at another size.
fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle {
text_style_bookmarks::TextStyle { size, ..style.clone() }
}
#[test]
fn number_pages_shows_in_the_viewer_and_undoes() {
let (mut s, id) = session_with(5);
+229
View File
@@ -0,0 +1,229 @@
//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how
//! they look (chapter titles in one font at one size, sections in another), not tagged as
//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the
//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level.
//!
//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are
//! subsets of one font), the size the text is shown at, and whether it is bold and italic.
//! Lines are taken in reading order down each page: top to bottom, then left to right.
use pdfcraft_cos::Document;
use pdfcraft_organize::OutlineEntry;
/// How a line of text looks.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyle {
/// The font's name, without a subset tag.
pub font: String,
/// The size the text is shown at, in points (rounded to a tenth).
pub size: f64,
pub bold: bool,
pub italic: bool,
}
impl TextStyle {
/// Whether `other` is this style. Sizes within a quarter point match, as the same heading
/// size is often written with rounding differences (12 and 11.96).
pub(crate) fn matches(&self, other: &TextStyle) -> bool {
self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE
}
}
/// How far apart two sizes may be and still be one style.
const SIZE_TOLERANCE: f64 = 0.25;
/// A style and how much text is set in it.
#[derive(Clone, Debug, PartialEq)]
pub struct TextStyleUse {
pub style: TextStyle,
/// Lines in this style.
pub lines: usize,
/// Pages they are on.
pub pages: usize,
/// The first line in it, cut to [`SAMPLE_CHARS`] characters.
pub sample: String,
}
/// The most pages read: a longer document is listed and searched by its first pages.
pub const MAX_PAGES: usize = 5000;
/// The most bookmarks made at once.
pub const MAX_BOOKMARKS: usize = 10_000;
/// The longest bookmark title kept, in characters (a heading run on into a paragraph).
const MAX_TITLE_CHARS: usize = 300;
/// The longest sample shown for a style.
const SAMPLE_CHARS: usize = 60;
/// The most styles listed, the largest first.
const MAX_STYLES: usize = 200;
/// One line of text in reading order.
struct Line {
page: usize,
text: String,
style: TextStyle,
/// Top, bottom, left and right of its box in user space.
top: f64,
bottom: f64,
left: f64,
right: f64,
}
/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2).
fn without_subset_tag(name: &str) -> &str {
match name.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest,
_ => name,
}
}
/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that
/// is only whitespace, or whose size can't be read, is left out.
fn lines(doc: &Document) -> Vec<Line> {
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<Line> = found
.into_iter()
.filter_map(|l| {
let text = l.text.split_whitespace().collect::<Vec<_>>().join(" ");
// `size` is the size the line is shown at in user space (the `Tf` size times the
// text rendering matrix), so `1 Tf` with a 14× text matrix is 14.
let size = l.size;
let [x0, y0, x1, y1] = l.rect;
let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite());
ok.then(|| Line {
page,
text,
style: TextStyle {
font: without_subset_tag(&l.base_font).to_string(),
size: (size * 10.0).round() / 10.0,
bold: l.bold,
italic: l.italic,
},
top: y0.max(y1),
bottom: y0.min(y1),
left: x0.min(x1),
right: x0.max(x1),
})
})
.collect();
// Down the page; along each row from the left. Boxes of one row differ by a fraction
// of a point, so rows are compared at a tenth of a point.
on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left)));
out.extend(on_page);
}
out
}
/// The styles `doc`'s text is set in, the largest size first (then the most used): headings
/// are usually larger than the text around them.
pub fn styles(doc: &Document) -> Vec<TextStyleUse> {
let mut uses: Vec<(TextStyleUse, Option<usize>)> = Vec::new();
// The styles by font, weight, slant and size in quarter points, so a line is matched against
// the few styles near its size rather than every style found (a page can hold thousands).
// A size matches within a quarter point: the bucket's neighbours are looked at too.
let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec<usize>> = std::collections::HashMap::new();
let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64;
for line in lines(doc) {
let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b);
let b = bucket(line.style.size);
let found = [b.saturating_sub(1), b, b.saturating_add(1)]
.into_iter()
.filter_map(|b| index.get(&key(b)))
.flatten()
.copied()
.find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style)));
match found.and_then(|i| uses.get_mut(i)) {
Some((u, last_page)) => {
u.lines = u.lines.saturating_add(1);
if *last_page != Some(line.page) {
u.pages = u.pages.saturating_add(1);
*last_page = Some(line.page);
}
}
None => {
let sample = line.text.chars().take(SAMPLE_CHARS).collect();
index.entry(key(b)).or_default().push(uses.len());
uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page)));
}
}
}
let mut uses: Vec<TextStyleUse> = 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<OutlineEntry> {
let mut entries: Vec<OutlineEntry> = Vec::new();
// The heading the previous line belonged to: its level, and the top, bottom, left and right
// of its last line.
let mut open: Option<(usize, f64, f64, f64, f64)> = None;
for line in lines(doc) {
let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else {
open = None;
continue;
};
// A heading goes on to the next row down, close below its last line and under it. A line
// on the same row (two headings side by side), or in another column, is a heading of
// its own.
let continues = open.is_some_and(|(l, top, bottom, left, right)| {
let size = line.style.size;
let under = line.left < right && left < line.right;
l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under
}) && entries.last().is_some_and(|e| e.page == line.page);
if continues {
if let Some(e) = entries.last_mut()
&& e.title.chars().count() < MAX_TITLE_CHARS
{
e.title.push(' ');
e.title.push_str(&line.text);
e.title = e.title.chars().take(MAX_TITLE_CHARS).collect();
}
} else {
if entries.len() >= MAX_BOOKMARKS {
break;
}
// The level counts from 1; the position in `levels` is at most its length, which a
// caller keeps small (a few levels), so it fits a u8.
let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX);
entries.push(OutlineEntry {
level,
title: line.text.chars().take(MAX_TITLE_CHARS).collect(),
page: line.page,
element: None,
top: Some(line.top),
});
}
open = Some((level, line.top, line.bottom, line.left, line.right));
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subset_tags_are_left_out_of_the_font_name() {
assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold");
assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold");
assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters");
assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters");
assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag");
assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x");
}
#[test]
fn styles_match_within_a_quarter_point() {
let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false };
assert!(s(12.0).matches(&s(11.8)));
assert!(!s(12.0).matches(&s(11.7)));
assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) }));
assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) }));
}
}
+8 -9
View File
@@ -518,14 +518,10 @@ fn destination_at(doc: &Document, page: usize, left: Option<f64>, top: Option<f6
Ok(go_to_at(*p, left, top))
}
/// `/XYZ null null null`: go 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(page: ObjRef) -> 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<f64>, top: Option<f64>) -> Object {
let coordinate = |v: Option<f64>| v.map_or(Object::Null, Object::Real);
Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null])
@@ -569,7 +565,7 @@ pub fn add_bookmark_at(
Ok(path)
}
/// One bookmark of a generated tree (New Bookmarks from Structure).
/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles).
#[derive(Clone, Debug, PartialEq)]
pub struct OutlineEntry {
/// 1 = directly under the new parent; deeper levels nest under the last shallower entry.
@@ -578,6 +574,9 @@ pub struct OutlineEntry {
pub page: usize,
/// The structure element it stands for (`/SE`).
pub element: Option<ObjRef>,
/// 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<f64>,
}
/// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by
@@ -601,7 +600,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl
for (e, page) in entries.iter().zip(targets) {
let mut d = Dict::new();
d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title)));
d.set(b"Dest".to_vec(), go_to(page));
d.set(b"Dest".to_vec(), go_to_at(page, None, e.top));
if let Some(se) = e.element {
d.set(b"SE".to_vec(), Object::Ref(se));
}
+1 -1
View File
@@ -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]);
+1
View File
@@ -222,6 +222,7 @@ impl PdfCraftApp {
pending_full_screen: None,
pending_remote_link: None,
protect_draft: Default::default(),
text_style_draft: Default::default(),
boxes_draft: Default::default(),
marks_draft: Default::default(),
export_draft: Default::default(),
+1
View File
@@ -371,6 +371,7 @@ impl PdfCraftApp {
}
"bookmark.add" => self.bookmark_action(crate::panels::BmAction::New),
"bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure),
"bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles),
"page.organize" => {
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
if v.organize {
+5
View File
@@ -8,6 +8,7 @@ use pdfcraft_engine::Edit;
use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, widgets};
pub(super) mod appearance;
pub mod bookmark_styles_ui;
pub(super) mod comment_props;
pub(super) mod credits;
pub(super) mod marks_ui;
@@ -421,6 +422,10 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
req.close = req.apply || cancel;
return false;
}
Dialog::BookmarksFromTextStyles => {
req.close = crate::bookmark_styles_ui::body(ui, app, &t);
return false;
}
Dialog::NumberPages => {
if !dialog_number_pages(ui, app, req) {
return false;
@@ -0,0 +1,134 @@
//! Bookmarks ▸ New bookmarks from text styles (#1693): for documents whose headings aren't
//! tagged, a style per bookmark level (the chapters' font and size for level 1, the sections'
//! for level 2, …), and every line set in it becomes a bookmark of that level
//! ([`pdfcraft_engine::text_style_bookmarks`]).
use pdfcraft_engine::text_style_bookmarks::{TextStyle, TextStyleUse};
use pdfcraft_engine::{DocId, Edit};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
/// The levels offered: deeper outlines are rare, and each level needs a style of its own.
const LEVELS: usize = 4;
/// What the dialog shows and what has been chosen in it.
#[derive(Debug, Default)]
pub struct TextStyleDraft {
/// The document the styles were read from.
pub doc: Option<DocId>,
/// Its text styles, the largest first.
pub styles: Vec<TextStyleUse>,
/// The style (an index into `styles`) for each level, level 1 first; a level without one
/// ends the list.
pub levels: [Option<usize>; 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<TextStyle> {
self.levels.iter().map_while(|l| l.and_then(|i| self.styles.get(i)).map(|u| u.style.clone())).collect()
}
}
impl PdfCraftApp {
/// Open New bookmarks from text styles for document `id`: its styles are read now, and the
/// largest is offered for level 1 when there is more than one (headings are larger than the
/// text around them; a single style has no headings to tell apart).
pub fn open_text_style_bookmarks(&mut self, id: DocId) {
// The style list includes samples copied from the document's text. Enforce the PDF's
// copy permission before extracting any of that text, not only when creating bookmarks.
let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy()));
if !can_copy {
self.notify_tr("The document's security settings don't allow copying text and images");
return;
}
let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default();
let first = (styles.len() > 1).then_some(0);
self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] };
self.dialog = Some(crate::Dialog::BookmarksFromTextStyles);
}
}
/// How a style is named in the lists: font, size, bold or italic, how many lines, and a sample.
fn style_label(u: &TextStyleUse) -> String {
let mut s = crate::i18n::fmt(tl!("{font}, {size} pt"), &[("font", &u.style.font), ("size", &format!("{}", u.style.size))]);
if u.style.bold {
s.push_str(", ");
s.push_str(tl!("bold"));
}
if u.style.italic {
s.push_str(", ");
s.push_str(tl!("italic"));
}
let lines = if u.lines == 1 { tl!("1 line").to_string() } else { crate::i18n::fmt(tl!("{n} lines"), &[("n", &u.lines.to_string())]) };
let sample: String = u.sample.chars().take(40).collect();
let more = if sample.chars().count() < u.sample.chars().count() { "…" } else { "" };
format!("{s} · “{sample}{more}” ({lines})")
}
/// The dialog's contents and its buttons. Returns whether it closes; Create makes the
/// bookmarks and shows them in the Bookmarks panel.
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
ui.label(egui::RichText::new(tl!("New bookmarks from text styles")).font(theme::semibold(18.0)));
ui.add_space(6.0);
let d = &mut app.text_style_draft;
let id = d.doc;
if d.styles.is_empty() {
ui.label(tl!("This document has no text to make bookmarks from."));
ui.add_space(12.0);
return widgets::pill_button(ui, tl!("Close"), true).clicked();
}
ui.label(
egui::RichText::new(tl!("Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark."))
.color(t.text_muted),
);
ui.add_space(8.0);
let none = tl!("None");
egui::Grid::new("text_style_levels").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for level in 0..LEVELS {
// A level takes a style only once the one above it has one.
let open = level == 0 || d.levels.get(level - 1).copied().flatten().is_some();
let label = ui.label(crate::i18n::fmt(tl!("Level {n}"), &[("n", &(level + 1).to_string())]));
ui.add_enabled_ui(open, |ui| {
let Some(slot) = d.levels.get_mut(level) else { return };
let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label);
craft_ui::ComboBox::from_id_salt(("text_style_level", level))
.selected_text(shown)
.width(460.0)
.show_ui(ui, |ui| {
ui.selectable_value(slot, None, none);
for (i, u) in d.styles.iter().enumerate() {
ui.selectable_value(slot, Some(i), style_label(u));
}
})
.response
.labelled_by(label.id);
});
ui.end_row();
}
});
// A level left without a style ends the list: the ones below it don't count.
if let Some(gap) = d.levels.iter().position(Option::is_none) {
d.levels.iter_mut().skip(gap).for_each(|l| *l = None);
}
let levels = d.chosen();
ui.add_space(16.0);
let mut close = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.add_enabled_ui(!levels.is_empty(), |ui| widgets::pill_button(ui, tl!("Create"), true)).inner.clicked() {
close = true;
// The document the styles came from, even if another tab is active by now.
if let Some(i) = id.and_then(|id| app.views.iter().position(|v| v.id == id)) {
app.active = Some(i);
if app.apply_edit(Edit::BookmarksFromTextStyles { levels }) {
app.right = Some(crate::RightPanel::Bookmarks);
}
}
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
close = true;
}
});
close
}
+5
View File
@@ -730,6 +730,11 @@ impl PdfCraftApp {
self.right = Some(crate::RightPanel::Bookmarks);
Edit::BookmarksFromStructure
}
// A dialog chooses the styles; it makes the edit.
A::FromTextStyles => {
self.open_text_style_bookmarks(id);
return;
}
};
self.apply_edit(edit);
}
+3
View File
@@ -82,6 +82,7 @@ use canvas::wheel_boost;
use canvas::wheel_pager;
use canvas::zoom_snap;
use dialogs::appearance;
pub use dialogs::bookmark_styles_ui;
use dialogs::comment_props;
use dialogs::credits;
use dialogs::marks_ui;
@@ -573,6 +574,8 @@ pub struct PdfCraftApp {
pub(crate) word_count_draft: word_count_ui::Draft,
/// Protect Using Password dialog state.
pub protect_draft: protect::ProtectDraft,
/// New bookmarks from text styles dialog state (#1693).
pub text_style_draft: bookmark_styles_ui::TextStyleDraft,
/// Set Page Boxes dialog state.
pub boxes_draft: pageboxes::BoxesDraft,
/// Header & footer / watermark / background dialog state.
+1
View File
@@ -194,6 +194,7 @@ const PAGES: &[&str] = &[
SEP,
"bookmark.add",
"bookmark.from_structure",
"bookmark.from_text_styles",
];
/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar.
const FILL: &[(&str, &str)] = &[
+2
View File
@@ -21,6 +21,8 @@ pub enum BmAction {
Outdent(Vec<usize>),
/// 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.
+4
View File
@@ -107,6 +107,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
bm_action = Some(BmAction::FromStructure);
ui.close();
}
if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() {
bm_action = Some(BmAction::FromTextStyles);
ui.close();
}
if !info.outline.is_empty() {
for (levels, label) in [
(usize::MAX, tl!("Expand all bookmarks")),
+2
View File
@@ -113,6 +113,8 @@ pub enum Dialog {
PageProps,
/// Protect Using Password.
Protect,
/// Bookmarks ▸ New bookmarks from text styles (#1693).
BookmarksFromTextStyles,
/// Set Page Boxes (crop, trim, bleed, art, media).
PageBoxes,
/// Add / Update Header and Footer, Watermark, Background.
+91
View File
@@ -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<u8> {
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<usize>)> = 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;
+12
View File
@@ -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"