mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
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:
@@ -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")?;
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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())?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(§ion, 30.0);
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err();
|
||||
assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style
|
||||
/// for two levels (or one within the size tolerance of the other) would leave the later level
|
||||
/// empty. Refused at the edit, for the dialog and automation alike; nothing changes.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
|
||||
let styles = s.get(id).unwrap().text_styles();
|
||||
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
|
||||
let near = pdfcraft_engine_style(§ion, section.size + 0.1);
|
||||
for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] {
|
||||
let n = levels.len();
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err();
|
||||
assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document
|
||||
/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_need_copying_permission() {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying());
|
||||
assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied");
|
||||
let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false };
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err();
|
||||
assert_eq!(err, EditError::NotPermitted("copying text and images"));
|
||||
let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
|
||||
assert!(!s.get(owner).unwrap().text_styles().is_empty());
|
||||
assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower,
|
||||
/// are two bookmarks, not one heading run on across the columns.
|
||||
#[test]
|
||||
fn headings_in_two_columns_are_two_bookmarks() {
|
||||
let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET";
|
||||
let bytes = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
|
||||
.stream("", page)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let style = s.get(id).unwrap().text_styles()[0].style.clone();
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap();
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]);
|
||||
}
|
||||
|
||||
/// `style` at another size.
|
||||
fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle {
|
||||
text_style_bookmarks::TextStyle { size, ..style.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_pages_shows_in_the_viewer_and_undoes() {
|
||||
let (mut s, id) = session_with(5);
|
||||
|
||||
@@ -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) }));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)] = &[
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user