mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 14:22:25 +00:00
275 lines
13 KiB
Rust
275 lines
13 KiB
Rust
use super::read::{fields, page_refs};
|
|
use super::{Document, Field, FormError, ObjRef, Object, invalid};
|
|
|
|
/// A page's tab order (`/Tabs`).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum TabOrder {
|
|
/// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound
|
|
/// on the right).
|
|
Row,
|
|
/// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a
|
|
/// document bound on the right).
|
|
Column,
|
|
/// The document structure (`/S`; annotation order when the file has none).
|
|
Structure,
|
|
/// Unspecified: annotation order.
|
|
Annotations,
|
|
}
|
|
|
|
impl TabOrder {
|
|
fn of(name: Option<&[u8]>) -> TabOrder {
|
|
match name {
|
|
Some(b"R") => TabOrder::Row,
|
|
Some(b"C") => TabOrder::Column,
|
|
Some(b"S") => TabOrder::Structure,
|
|
_ => TabOrder::Annotations,
|
|
}
|
|
}
|
|
|
|
/// The tab order of the page at `r`, with an indirect `/Tabs` resolved.
|
|
fn of_page(doc: &Document, r: ObjRef) -> TabOrder {
|
|
let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o));
|
|
TabOrder::of(tabs.as_deref().and_then(Object::as_name))
|
|
}
|
|
}
|
|
|
|
/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index).
|
|
type TabItem = (usize, usize, usize, [f64; 4], usize);
|
|
|
|
/// Every widget's place in the tab order.
|
|
pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::collections::HashMap<ObjRef, usize>, out: &mut [Field]) {
|
|
let mut items: Vec<TabItem> = Vec::new();
|
|
for (fi, f) in out.iter().enumerate() {
|
|
for (wi, w) in f.widgets.iter().enumerate() {
|
|
if let Some(p) = w.page {
|
|
items.push((p, fi, wi, w.rect, annot_index.get(&w.obj).copied().unwrap_or(usize::MAX)));
|
|
}
|
|
}
|
|
}
|
|
// Rows and columns run right to left in a document bound on the right: rank the mirrored
|
|
// rectangles there.
|
|
if right_to_left(doc) {
|
|
for i in &mut items {
|
|
i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]];
|
|
}
|
|
}
|
|
let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len());
|
|
for (pi, p) in pages.iter().enumerate() {
|
|
let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect();
|
|
if on.is_empty() {
|
|
continue;
|
|
}
|
|
let tabs = TabOrder::of_page(doc, *p);
|
|
match tabs {
|
|
TabOrder::Row | TabOrder::Column => {
|
|
let row = tabs == TabOrder::Row;
|
|
// Primary axis: rows by top edge (down the page), columns by left edge.
|
|
on.sort_by(|a, b| if row { b.3[3].total_cmp(&a.3[3]) } else { a.3[0].total_cmp(&b.3[0]) });
|
|
let mut groups: Vec<Vec<&TabItem>> = Vec::new();
|
|
for it in on {
|
|
let fits = groups.last().is_some_and(|g| {
|
|
let first = g[0];
|
|
if row {
|
|
(first.3[3] - it.3[3]).abs() < ((first.3[3] - first.3[1]) / 2.0).max(2.0)
|
|
} else {
|
|
(it.3[0] - first.3[0]).abs() < ((first.3[2] - first.3[0]) / 2.0).max(2.0)
|
|
}
|
|
});
|
|
if fits && let Some(g) = groups.last_mut() {
|
|
g.push(it);
|
|
} else {
|
|
groups.push(vec![it]);
|
|
}
|
|
}
|
|
for mut g in groups {
|
|
g.sort_by(|a, b| if row { a.3[0].total_cmp(&b.3[0]) } else { b.3[3].total_cmp(&a.3[3]) });
|
|
ordered.extend(g.iter().map(|i| (i.1, i.2)));
|
|
}
|
|
}
|
|
TabOrder::Structure | TabOrder::Annotations => {
|
|
on.sort_by_key(|i| i.4);
|
|
ordered.extend(on.iter().map(|i| (i.1, i.2)));
|
|
}
|
|
}
|
|
}
|
|
for f in out.iter_mut() {
|
|
for w in &mut f.widgets {
|
|
w.tab = usize::MAX;
|
|
}
|
|
}
|
|
for (rank, (fi, wi)) in ordered.into_iter().enumerate() {
|
|
out[fi].widgets[wi].tab = rank;
|
|
}
|
|
}
|
|
|
|
/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`).
|
|
fn right_to_left(doc: &Document) -> bool {
|
|
let Some(root) = doc.root() else { return false };
|
|
let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false };
|
|
prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L"))
|
|
}
|
|
|
|
/// Set the tab order of pages (0-based).
|
|
pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> {
|
|
let refs = page_refs(doc);
|
|
for &p in pages {
|
|
let r = *refs.get(p).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", p + 1)))?;
|
|
doc.update_dict(r, |d| match order {
|
|
TabOrder::Row => d.set(b"Tabs".to_vec(), Object::name("R")),
|
|
TabOrder::Column => d.set(b"Tabs".to_vec(), Object::name("C")),
|
|
TabOrder::Structure => d.set(b"Tabs".to_vec(), Object::name("S")),
|
|
TabOrder::Annotations => {
|
|
d.remove(b"Tabs");
|
|
}
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified).
|
|
pub fn tab_orders(doc: &Document) -> Vec<TabOrder> {
|
|
page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect()
|
|
}
|
|
|
|
/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field):
|
|
/// move field `name` one place earlier or later in its page's tab order. The page switches to
|
|
/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other
|
|
/// annotations keep their places.
|
|
pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> {
|
|
let all = fields(doc);
|
|
if !all.iter().any(|f| f.name == name) {
|
|
return Err(FormError::NoSuchField(name.into()));
|
|
}
|
|
let (target_fi, page) = all
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, f)| f.name == name)
|
|
.find_map(|(fi, f)| Some((fi, f.widgets.iter().filter_map(|w| w.page).min()?)))
|
|
.ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?;
|
|
// The page's fields in their current tab order (a field counts once, at its first widget).
|
|
let mut order: Vec<(usize, usize)> = all
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(fi, g)| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, fi)))
|
|
.collect();
|
|
order.sort();
|
|
let Some(i) = order.iter().position(|&(_, fi)| fi == target_fi) else { return invalid(format!("{name} is not on a page")) };
|
|
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < order.len()) };
|
|
let Some(j) = j else { return Ok(()) };
|
|
order.swap(i, j);
|
|
// The widgets of this page in the new order, preserving each field's relative widget tab order.
|
|
let mut widgets: Vec<ObjRef> = Vec::new();
|
|
let mut queued = std::collections::HashSet::new();
|
|
for &(_, fi) in &order {
|
|
if let Some(g) = all.get(fi) {
|
|
let mut own: Vec<(usize, ObjRef)> = g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| (w.tab, w.obj)).collect();
|
|
own.sort_by_key(|&(tab, _)| tab);
|
|
widgets.extend(own.into_iter().map(|(_, obj)| obj).filter(|obj| queued.insert(*obj)));
|
|
}
|
|
}
|
|
let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?;
|
|
let annots_obj =
|
|
doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()).ok_or_else(|| FormError::Invalid("the page has no annotations".into()))?;
|
|
let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default();
|
|
let mut next = widgets.into_iter();
|
|
let set: std::collections::HashSet<ObjRef> = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect();
|
|
let mut seen_slots = std::collections::HashSet::new();
|
|
let new_list: Vec<Object> = list
|
|
.iter()
|
|
.map(|o| match o.as_ref() {
|
|
Some(r) if set.contains(&r) && seen_slots.insert(r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()),
|
|
_ => o.clone(),
|
|
})
|
|
.collect();
|
|
match annots_obj {
|
|
Object::Ref(ar) => doc.set(ar, Object::Array(new_list)),
|
|
_ => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(new_list)))?,
|
|
}
|
|
doc.update_dict(pr, |d| {
|
|
d.remove(b"Tabs");
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pdfcraft_cos::{Dict, PdfString};
|
|
|
|
#[test]
|
|
fn move_in_tab_order_preserves_multi_widget_relative_tab_order() {
|
|
let mut doc = Document::new_empty();
|
|
let pages_ref = doc.add(Object::Null);
|
|
let page_ref = doc.add(Object::Null);
|
|
|
|
let mut w1 = Dict::new();
|
|
w1.set(b"Subtype".to_vec(), Object::name("Widget"));
|
|
w1.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(200), Object::Int(700), Object::Int(220), Object::Int(720)]));
|
|
let w1_ref = doc.add(Object::Dict(w1));
|
|
|
|
let mut w2 = Dict::new();
|
|
w2.set(b"Subtype".to_vec(), Object::name("Widget"));
|
|
w2.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(700), Object::Int(70), Object::Int(720)]));
|
|
let w2_ref = doc.add(Object::Dict(w2));
|
|
|
|
let mut radio = Dict::new();
|
|
radio.set(b"FT".to_vec(), Object::name("Btn"));
|
|
radio.set(b"Ff".to_vec(), Object::Int(1 << 15));
|
|
radio.set(b"T".to_vec(), PdfString::text("choice"));
|
|
// /Kids order is [w1 (right), w2 (left)], opposite to /R (Row) left-to-right order.
|
|
radio.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref)]));
|
|
let radio_ref = doc.add(Object::Dict(radio));
|
|
doc.update_dict(w1_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap();
|
|
doc.update_dict(w2_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap();
|
|
|
|
let mut text = Dict::new();
|
|
text.set(b"FT".to_vec(), Object::name("Tx"));
|
|
text.set(b"Subtype".to_vec(), Object::name("Widget"));
|
|
text.set(b"T".to_vec(), PdfString::text("note"));
|
|
text.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(600), Object::Int(150), Object::Int(620)]));
|
|
let text_ref = doc.add(Object::Dict(text));
|
|
|
|
let mut page = Dict::new();
|
|
page.set(b"Type".to_vec(), Object::name("Page"));
|
|
page.set(b"Parent".to_vec(), Object::Ref(pages_ref));
|
|
page.set(b"Tabs".to_vec(), Object::name("R"));
|
|
page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref), Object::Ref(text_ref)]));
|
|
doc.set(page_ref, Object::Dict(page));
|
|
|
|
let mut pages = Dict::new();
|
|
pages.set(b"Type".to_vec(), Object::name("Pages"));
|
|
pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page_ref)]));
|
|
pages.set(b"Count".to_vec(), Object::Int(1));
|
|
doc.set(pages_ref, Object::Dict(pages));
|
|
|
|
let mut af = Dict::new();
|
|
af.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref)]));
|
|
let af_ref = doc.add(Object::Dict(af));
|
|
|
|
let mut catalog = Dict::new();
|
|
catalog.set(b"Type".to_vec(), Object::name("Catalog"));
|
|
catalog.set(b"Pages".to_vec(), Object::Ref(pages_ref));
|
|
catalog.set(b"AcroForm".to_vec(), Object::Ref(af_ref));
|
|
let root_ref = doc.root().unwrap();
|
|
doc.set(root_ref, Object::Dict(catalog));
|
|
|
|
// Move "note" earlier (before "choice"): "choice"'s own buttons should stay in their
|
|
// /R tab order (w2 then w1), not revert to /Kids order (w1 then w2).
|
|
move_in_tab_order(&mut doc, "note", true).unwrap();
|
|
let annots = doc.get(page_ref).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap();
|
|
assert_eq!(annots, vec![Object::Ref(text_ref), Object::Ref(w2_ref), Object::Ref(w1_ref)]);
|
|
|
|
// Missing page and unplaced field return errors cleanly.
|
|
assert!(set_tab_order(&mut doc, &[99], TabOrder::Row).is_err());
|
|
let mut unplaced = Dict::new();
|
|
unplaced.set(b"FT".to_vec(), Object::name("Tx"));
|
|
unplaced.set(b"T".to_vec(), PdfString::text("orphan"));
|
|
let unplaced_ref = doc.add(Object::Dict(unplaced));
|
|
doc.update_dict(af_ref, |d| {
|
|
d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref), Object::Ref(unplaced_ref)]));
|
|
})
|
|
.unwrap();
|
|
assert!(move_in_tab_order(&mut doc, "orphan", true).is_err());
|
|
}
|
|
}
|