mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 05:28:09 -06:00
M5: Import comments takes another PDF and copies its comments onto the same pages (#4595)
* M5: Import comments takes another PDF and copies its comments onto the same pages Import comments read only XFDF, FDF, XML, XDP, CSV and text data files. It now also takes a PDF: each comment of that PDF is copied onto the page with the same number, at the same place, with its appearance stream, its pop-up, its replies and its layer. Comments on pages past the document's last page are left out, together with replies to them. Links, form fields and redaction marks are not copied. A comment named like one already on its page replaces it, as an XFDF or FDF import does. A PDF without comments is refused with "nothing in the file matches this document", and a PDF that needs a password is refused. - organize: import_comments copies the comments with the page copier that Insert Pages uses, and registers the layers they use. - engine: Edit::ImportData reads a file that starts with %PDF- as a PDF. Any other file goes to the data importer first, and is read as a PDF when that fails, %PDF- occurs in its first 1,024 bytes and the PDF reader opens it. - ui: Comments > Import comments lists .pdf files; form.import_data lists the same files as before. - automation: the doc_import_data description names the PDF case. - parity: comment.import-from-pdf is shipped. * Import comments: an annotation that is no comment keeps its name A comment imported from a PDF replaced any annotation of the same /NM on its page that was not locked, a redaction mark or a widget, so a link, screen or 3D annotation of that name was overwritten. Only an annotation whose subtype is one of the copied comment subtypes is replaced now; for any other, the copy is added beside it without the name. The subtype is read through an indirect reference. * Import comments: index each page's annotations instead of scanning them per comment * Import comments: a layer that BaseState OFF hides stays hidden * Import comments: a PDF with binary bytes before its header imports on an XFA form, and two comments with one name both land On an XFA form, a decode failure no longer stops the import when a PDF header is in the first 1,024 bytes; the PDF's comments are copied. Two incoming comments on one page with the same /NM no longer replace the same comment: the first replaces it, and the second is added without the name. * Import comments: an inline annotation on the page is replaced by name An annotation written inline in a page's /Annots is made an indirect object before names are matched, so an incoming comment with its /NM replaces it instead of being added beside it. * Import comments: two new comments with one name keep it once When no comment on the page has the name, the first incoming comment keeps its /NM and each later one with the same name is added without it.
This commit is contained in:
@@ -511,7 +511,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
|
||||
.destructive()
|
||||
|
||||
@@ -522,7 +522,8 @@ pub enum Edit {
|
||||
links: Vec<(usize, Vec<[f64; 4]>, String)>,
|
||||
style: LinkStyle,
|
||||
},
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text).
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the
|
||||
/// comments of another PDF onto the pages with the same numbers.
|
||||
ImportData {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::{
|
||||
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
@@ -664,7 +666,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
|
||||
}
|
||||
}
|
||||
Edit::ImportData { bytes, .. } => {
|
||||
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
|
||||
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
|
||||
import_pdf_comments(doc, &src)?;
|
||||
}
|
||||
Edit::ImportData { name, bytes } => {
|
||||
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
|
||||
// XFDF, FDF, Acrobat's XML and tables go the usual way.
|
||||
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
|
||||
@@ -678,9 +684,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
|
||||
xfa_after_data_change(doc, cx, true)?;
|
||||
}
|
||||
Some(Err(why)) => return Err(EditError::Invalid(why)),
|
||||
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
|
||||
_ => {
|
||||
pdfcraft_xfdf::import(doc, bytes)?;
|
||||
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
|
||||
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
|
||||
// the data importer's error.
|
||||
let mut data = doc.clone();
|
||||
match pdfcraft_xfdf::import(&mut data, bytes) {
|
||||
Ok(_) => *doc = data,
|
||||
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
|
||||
Ok(src) => import_pdf_comments(doc, &src)?,
|
||||
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
|
||||
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
|
||||
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
|
||||
if pdfcraft_organize::import_comments(doc, src)? == 0 {
|
||||
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse another PDF to import comments from, whatever its permissions allow.
|
||||
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
||||
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
|
||||
@@ -8897,6 +8897,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() {
|
||||
assert!(blue.count() > 20, "the pilcrow is drawn beside the caret");
|
||||
}
|
||||
|
||||
/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses
|
||||
/// a PDF that has none.
|
||||
#[test]
|
||||
fn importing_a_pdf_copies_its_comments() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap();
|
||||
let review = pdf_with_annots(&[
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
|
||||
]);
|
||||
s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap();
|
||||
let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link");
|
||||
assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf");
|
||||
assert!(s.get(id).unwrap().info.annotations.is_empty());
|
||||
|
||||
// Bytes before the header, as the reader allows them.
|
||||
let prefixed = [b"\r\n \n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())]);
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]);
|
||||
let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string());
|
||||
// A PDF that needs a password is refused with that reason, with or without bytes before its header.
|
||||
let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap();
|
||||
locked
|
||||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap();
|
||||
for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] {
|
||||
let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string());
|
||||
}
|
||||
|
||||
// An XFDF file that mentions the PDF header is still read as XFDF.
|
||||
let xfdf = r#"<?xml version="1.0"?><!-- exported from a file starting %PDF-1.7 --><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><text page="0" name="n1" rect="10,10,30,30"><contents>from the data file</contents></text></annots></xfdf>"#;
|
||||
s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the data file".to_string())]);
|
||||
}
|
||||
|
||||
/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of
|
||||
/// refusing it as a data file in an unknown encoding.
|
||||
#[test]
|
||||
fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() {
|
||||
let data = "<form><page1><country>JP</country></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]);
|
||||
let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
|
||||
assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}");
|
||||
}
|
||||
|
||||
/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …).
|
||||
fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF
|
||||
//! chosen): each comment is copied onto the page with the same number, at the same place, with
|
||||
//! its appearance, pop-up and replies.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::import::{Copier, register_layers};
|
||||
use crate::{OrganizeError, walk};
|
||||
|
||||
/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171,
|
||||
/// except redaction marks (`Redact`) and `Projection`.
|
||||
const COMMENTS: [&[u8]; 16] = [
|
||||
b"Text",
|
||||
b"FreeText",
|
||||
b"Line",
|
||||
b"Square",
|
||||
b"Circle",
|
||||
b"Polygon",
|
||||
b"PolyLine",
|
||||
b"Highlight",
|
||||
b"Underline",
|
||||
b"Squiggly",
|
||||
b"StrikeOut",
|
||||
b"Stamp",
|
||||
b"Caret",
|
||||
b"Ink",
|
||||
b"FileAttachment",
|
||||
b"Sound",
|
||||
];
|
||||
|
||||
/// The Locked annotation flag.
|
||||
const LOCKED: i64 = 128;
|
||||
|
||||
/// One comment of the source: its entry in the page's `/Annots` and its dictionary.
|
||||
struct Incoming {
|
||||
entry: Object,
|
||||
dict: Dict,
|
||||
}
|
||||
|
||||
/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of
|
||||
/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the
|
||||
/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its
|
||||
/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are
|
||||
/// not copied.
|
||||
///
|
||||
/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object
|
||||
/// so replies to it stay attached. A locked comment, or an annotation of that name that is no
|
||||
/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it
|
||||
/// without the name. Returns the number of comments copied.
|
||||
pub fn import_comments(dst: &mut Document, src: &Document) -> Result<usize, OrganizeError> {
|
||||
let from = walk(src)?;
|
||||
let to = walk(dst)?;
|
||||
let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect();
|
||||
let mut incoming: Vec<Vec<Incoming>> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect();
|
||||
let kept = threads_kept(&incoming);
|
||||
for list in &mut incoming {
|
||||
list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p))));
|
||||
}
|
||||
let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect());
|
||||
// Every comment's object is chosen before any is copied, so a comment that another one reaches
|
||||
// through `/IRT` is copied into the object chosen for it by name.
|
||||
let mut lists: Vec<Option<PageAnnots>> = Vec::new();
|
||||
let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new();
|
||||
for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() {
|
||||
if list.is_empty() {
|
||||
lists.push(None);
|
||||
continue;
|
||||
}
|
||||
let mut annots = PageAnnots::read(dst, *page);
|
||||
for Incoming { entry, dict } in list {
|
||||
// The same comment listed twice on a page is copied once.
|
||||
if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) {
|
||||
continue;
|
||||
}
|
||||
let (target, dict) = reserve(dst, src, &mut annots, dict);
|
||||
if let Some(r) = entry.as_ref() {
|
||||
copier.map.insert(r, target);
|
||||
}
|
||||
annots.push(target);
|
||||
planned.push((i, target, dict));
|
||||
}
|
||||
lists.push(Some(annots));
|
||||
}
|
||||
let mut annot_pages = HashMap::new();
|
||||
for (i, target, dict) in &planned {
|
||||
let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?;
|
||||
dst.set(*target, copy);
|
||||
annot_pages.insert(*target, pages[*i].1);
|
||||
}
|
||||
for (i, target, _) in &planned {
|
||||
if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup"))
|
||||
&& let Some(annots) = lists[*i].as_mut()
|
||||
{
|
||||
annots.push(popup);
|
||||
annot_pages.insert(popup, pages[*i].1);
|
||||
}
|
||||
}
|
||||
// A pop-up that only its `/Parent` ties to a copied comment comes along too.
|
||||
for (i, (from, to)) in pages.iter().enumerate() {
|
||||
let Some(annots) = lists[i].as_mut() else { continue };
|
||||
for (popup, parent) in page_popups(src, *from) {
|
||||
if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) {
|
||||
continue;
|
||||
}
|
||||
let r = dst.add(Object::Null);
|
||||
copier.map.insert(popup, r);
|
||||
let copy = copier.copy_value(dst, &src.get(popup), Some(r))?;
|
||||
dst.set(r, copy);
|
||||
annots.push(r);
|
||||
annot_pages.insert(r, *to);
|
||||
}
|
||||
}
|
||||
for ((_, page), annots) in pages.iter().zip(lists) {
|
||||
if let Some(annots) = annots {
|
||||
dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?;
|
||||
}
|
||||
}
|
||||
copier.fix_annotations(dst, &annot_pages)?;
|
||||
register_layers(dst, src, &copier.ocgs)?;
|
||||
Ok(planned.len())
|
||||
}
|
||||
|
||||
/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new
|
||||
/// object. Returns it with the dictionary to copy.
|
||||
fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) {
|
||||
let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text()));
|
||||
let same = nm.as_deref().and_then(|nm| annots.named(dst, nm));
|
||||
// A second incoming comment with the name of one added earlier is added without the name.
|
||||
let taken = nm.is_some_and(|nm| !annots.added.insert(nm));
|
||||
let target = match same {
|
||||
None if taken => {
|
||||
dict.remove(b"NM");
|
||||
dst.add(Object::Null)
|
||||
}
|
||||
Some((r, false)) => {
|
||||
annots.claimed.insert(r);
|
||||
// The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent`
|
||||
// is the comment.
|
||||
if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) {
|
||||
annots.remove(p);
|
||||
}
|
||||
for p in annots.popups.get(&r).cloned().unwrap_or_default() {
|
||||
annots.remove(p);
|
||||
}
|
||||
r
|
||||
}
|
||||
Some((_, true)) => {
|
||||
dict.remove(b"NM");
|
||||
dst.add(Object::Null)
|
||||
}
|
||||
None => dst.add(Object::Null),
|
||||
};
|
||||
(target, dict)
|
||||
}
|
||||
|
||||
/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one
|
||||
/// only with the comment it answers, up to the first comment of the thread. A thread whose replies
|
||||
/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once.
|
||||
fn threads_kept(incoming: &[Vec<Incoming>]) -> HashSet<ObjRef> {
|
||||
// For each comment: no parent, a parent comment, or a parent that is no comment of the source.
|
||||
let parents: HashMap<ObjRef, Option<Option<ObjRef>>> =
|
||||
incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect();
|
||||
let mut known: HashMap<ObjRef, bool> = HashMap::new();
|
||||
for &start in parents.keys() {
|
||||
let (mut path, mut on_path) = (Vec::new(), HashSet::new());
|
||||
let mut at = start;
|
||||
let kept = loop {
|
||||
if let Some(&k) = known.get(&at) {
|
||||
break k;
|
||||
}
|
||||
if !on_path.insert(at) {
|
||||
break true;
|
||||
}
|
||||
match parents.get(&at) {
|
||||
None => break false,
|
||||
Some(None) => {
|
||||
path.push(at);
|
||||
break true;
|
||||
}
|
||||
Some(Some(None)) => {
|
||||
path.push(at);
|
||||
break false;
|
||||
}
|
||||
Some(Some(Some(parent))) => {
|
||||
path.push(at);
|
||||
at = *parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
for r in path {
|
||||
known.insert(r, kept);
|
||||
}
|
||||
}
|
||||
known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect()
|
||||
}
|
||||
|
||||
/// The comments in `page`'s `/Annots`, in order.
|
||||
fn page_comments(src: &Document, page: ObjRef) -> Vec<Incoming> {
|
||||
let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned());
|
||||
list.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let dict = src.resolve(&entry).as_dict().cloned()?;
|
||||
let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?;
|
||||
COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`).
|
||||
fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> {
|
||||
let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned());
|
||||
list.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let r = entry.as_ref()?;
|
||||
let d = src.get(r).as_dict().cloned()?;
|
||||
(d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))?
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it
|
||||
/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a
|
||||
/// redaction mark or a form widget.
|
||||
/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not
|
||||
/// scan the whole list.
|
||||
struct PageAnnots {
|
||||
list: Vec<Object>,
|
||||
members: HashSet<ObjRef>,
|
||||
/// The annotations on the page before the import, by `/NM`, in list order.
|
||||
names: HashMap<String, Vec<ObjRef>>,
|
||||
/// The pop-ups on the page before the import, by `/Parent`.
|
||||
popups: HashMap<ObjRef, Vec<ObjRef>>,
|
||||
removed: HashSet<ObjRef>,
|
||||
/// The annotations that an incoming comment has already replaced.
|
||||
claimed: HashSet<ObjRef>,
|
||||
/// The names of the incoming comments placed so far.
|
||||
added: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PageAnnots {
|
||||
/// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects.
|
||||
fn read(dst: &mut Document, page: ObjRef) -> Self {
|
||||
let list: Vec<Object> = dst
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|p| p.get(b"Annots").cloned())
|
||||
.map(|a| dst.resolve(&a))
|
||||
.and_then(|a| a.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
let list: Vec<Object> = list
|
||||
.into_iter()
|
||||
.map(|a| match a {
|
||||
Object::Dict(_) => Object::Ref(dst.add(a)),
|
||||
a => a,
|
||||
})
|
||||
.collect();
|
||||
let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::<String, Vec<ObjRef>>::new(), HashMap::<ObjRef, Vec<ObjRef>>::new());
|
||||
for r in list.iter().filter_map(Object::as_ref) {
|
||||
members.insert(r);
|
||||
let Some(d) = dst.get(r).as_dict().cloned() else { continue };
|
||||
if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) {
|
||||
names.entry(name).or_default().push(r);
|
||||
}
|
||||
if d.name(b"Subtype") == Some(b"Popup".as_slice())
|
||||
&& let Some(parent) = d.reference(b"Parent")
|
||||
{
|
||||
popups.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Add `r` at the end, unless the page already lists it.
|
||||
fn push(&mut self, r: ObjRef) {
|
||||
if self.removed.remove(&r) {
|
||||
self.list.retain(|a| a.as_ref() != Some(r));
|
||||
}
|
||||
if self.members.insert(r) {
|
||||
self.list.push(Object::Ref(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take every entry for `r` off the page.
|
||||
fn remove(&mut self, r: ObjRef) {
|
||||
if self.members.remove(&r) {
|
||||
self.removed.insert(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or
|
||||
/// it is no comment.
|
||||
fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> {
|
||||
let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r));
|
||||
let first = *listed.clone().next()?;
|
||||
// A second incoming comment with the name of one already replaced is added beside it.
|
||||
let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) };
|
||||
let d = dst.get(r).as_dict().cloned()?;
|
||||
let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0);
|
||||
let subtype = d.get(b"Subtype").map(|s| dst.resolve(s));
|
||||
let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s));
|
||||
Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT")))
|
||||
}
|
||||
|
||||
/// The new `/Annots`.
|
||||
fn into_list(self) -> Vec<Object> {
|
||||
let removed = self.removed;
|
||||
self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build(bodies: &[&str]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
/// The reviewer's copy: three pages of comments, a link and a pop-up among them.
|
||||
fn reviewed() -> Document {
|
||||
build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream",
|
||||
"<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// The document receiving the comments: two pages, three comments of its own on page 1.
|
||||
fn original() -> Document {
|
||||
build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>",
|
||||
])
|
||||
}
|
||||
|
||||
fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> {
|
||||
let (p, _) = walk(doc).unwrap()[page].clone();
|
||||
let list =
|
||||
doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default();
|
||||
list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn text(d: &Dict, key: &[u8]) -> Option<String> {
|
||||
d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text())
|
||||
}
|
||||
|
||||
fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) {
|
||||
list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() {
|
||||
let mut doc = original();
|
||||
let src = reviewed();
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2");
|
||||
let pages: Vec<ObjRef> = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect();
|
||||
let one = page_annots(&doc, 0);
|
||||
for (_, d) in &one {
|
||||
assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on");
|
||||
}
|
||||
|
||||
// The note named like one already there replaces it in place, with its new pop-up.
|
||||
let (note, d) = by_contents(&one, "from the reviewer");
|
||||
assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object");
|
||||
let popup = d.reference(b"Popup").unwrap();
|
||||
assert_ne!(popup, ObjRef::new(8, 0));
|
||||
assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note));
|
||||
let popups: Vec<ObjRef> = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect();
|
||||
assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once");
|
||||
assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note");
|
||||
|
||||
// The locked comment of that name stays; the copy is added beside it, unnamed.
|
||||
assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked"));
|
||||
assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None);
|
||||
assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay");
|
||||
|
||||
// The stamp keeps its appearance; the redaction mark is not copied.
|
||||
let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap();
|
||||
let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied");
|
||||
assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice())));
|
||||
|
||||
// Page 3 is past the document's last page: its square and the reply to it are left out.
|
||||
assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3")));
|
||||
assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up");
|
||||
|
||||
// Page 2 takes the highlight, not the link.
|
||||
let two = page_annots(&doc, 1);
|
||||
assert_eq!(two.len(), 1);
|
||||
assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice()));
|
||||
assert_eq!(two[0].1.reference(b"P"), Some(pages[1]));
|
||||
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() {
|
||||
// Page 1 holds a reply to a note on page 2, named like the document's own note there.
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>",
|
||||
// A pop-up that only its /Parent ties to the note.
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>",
|
||||
// The same name in UTF-16BE, and a pop-up the note does not name.
|
||||
"<< /Type /Annot /Subtype /Text /NM <FEFF006E006F00740065002D0032> /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let two = page_annots(&doc, 1);
|
||||
assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes");
|
||||
assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer")));
|
||||
assert_ne!(two[1].0, ObjRef::new(6, 0));
|
||||
assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0)));
|
||||
assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true)));
|
||||
assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0));
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_keeps_its_layer_and_the_layer_its_visibility() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /OCG /Name (Review) >>",
|
||||
]);
|
||||
let mut doc = original();
|
||||
import_comments(&mut doc, &src).unwrap();
|
||||
let (_, square) =
|
||||
page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap();
|
||||
let layer = square.reference(b"OC").unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)])));
|
||||
let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_hidden_by_the_base_state_stays_hidden() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /OCG /Name (Review) >>",
|
||||
]);
|
||||
let mut doc = original();
|
||||
import_comments(&mut doc, &src).unwrap();
|
||||
let (_, square) =
|
||||
page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap();
|
||||
let layer = square.reference(b"OC").unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
|
||||
let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden");
|
||||
assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() {
|
||||
// Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3,
|
||||
// which the document has no page for. Then two replies that answer each other.
|
||||
let n = 20_000;
|
||||
let first = 6;
|
||||
let mut bodies: Vec<String> =
|
||||
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()];
|
||||
let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect();
|
||||
bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>"));
|
||||
bodies.push("<< /Type /Page /Parent 2 0 R >>".into());
|
||||
let square = first + n + 2;
|
||||
bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>"));
|
||||
for o in first..first + n {
|
||||
let parent = if o + 1 == first + n { square } else { o + 1 };
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
}
|
||||
let (a, b) = (first + n, first + n + 1);
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into());
|
||||
let src = build(&bodies.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let mut doc = original();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
let one = page_annots(&doc, 0);
|
||||
let (one_ref, one_dict) = by_contents(&one, "one");
|
||||
let (two_ref, two_dict) = by_contents(&one, "two");
|
||||
assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_comment_is_replaced_by_name() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>",
|
||||
]);
|
||||
// A link and a screen annotation named like two of the comments, and a comment whose
|
||||
// subtype is an indirect object.
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>",
|
||||
"/Text",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 3);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced");
|
||||
let (note, d) = by_contents(&one, "newer text");
|
||||
assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place");
|
||||
assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice())));
|
||||
assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1"));
|
||||
assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice())));
|
||||
assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1"));
|
||||
assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name");
|
||||
assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_comments_with_one_name_replace_one_comment_and_add_the_other() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 2, "one comment replaced and one added");
|
||||
let (first, d) = by_contents(&one, "first");
|
||||
assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin")));
|
||||
assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_new_comments_with_one_name_keep_it_once() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 2);
|
||||
assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin"));
|
||||
assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_comment_is_replaced_by_name() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 1);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy");
|
||||
assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_without_comments_copies_none() {
|
||||
let mut doc = original();
|
||||
let before = page_annots(&doc, 0).len();
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 0);
|
||||
assert_eq!(page_annots(&doc, 0).len(), before);
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ pub use combine::{
|
||||
CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with,
|
||||
extract_pages, split, split_ranges,
|
||||
};
|
||||
pub(crate) use copy::copy_object;
|
||||
use copy::{Copier, SKIP_ON_PAGE};
|
||||
use copy::SKIP_ON_PAGE;
|
||||
pub(crate) use copy::{Copier, copy_object};
|
||||
use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field};
|
||||
pub use fields::{combine_field_conflicts, field_name_conflicts};
|
||||
|
||||
@@ -432,18 +432,22 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen:
|
||||
|
||||
/// Register copied optional content groups in the destination's `/OCProperties`, keeping each
|
||||
/// layer's default visibility from the source.
|
||||
fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
|
||||
pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
|
||||
if ocgs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let src_off: Vec<Object> = src
|
||||
let src_config: Dict = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d)))
|
||||
.and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.and_then(|d| d.as_dict().cloned())
|
||||
.unwrap_or_default();
|
||||
let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF"));
|
||||
// With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101).
|
||||
let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice()));
|
||||
let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r));
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
|
||||
let (props_ref, mut props) = match catalog.get(b"OCProperties") {
|
||||
@@ -462,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]
|
||||
}
|
||||
all.push(r.clone());
|
||||
order.push(r.clone());
|
||||
if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
|
||||
if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
|
||||
}
|
||||
props.set(b"OCGs".to_vec(), Object::Array(all));
|
||||
config.set(b"ON".to_vec(), Object::Array(on));
|
||||
|
||||
@@ -43,11 +43,11 @@ pub(super) const WIDGET_KEYS: &[&[u8]] = &[
|
||||
b"AF",
|
||||
];
|
||||
|
||||
pub(super) struct Copier<'a> {
|
||||
pub(crate) struct Copier<'a> {
|
||||
pub(super) src: &'a Document,
|
||||
/// Current recursion depth, bounded by [`MAX_COPY_DEPTH`].
|
||||
depth: usize,
|
||||
pub(super) map: HashMap<ObjRef, ObjRef>,
|
||||
pub(crate) map: HashMap<ObjRef, ObjRef>,
|
||||
/// Source page → destination page, for rewriting `/P` and destinations.
|
||||
pub(super) pages: HashMap<ObjRef, ObjRef>,
|
||||
/// Destination objects that are annotation dictionaries (fixed up after copying).
|
||||
@@ -55,11 +55,11 @@ pub(super) struct Copier<'a> {
|
||||
/// Top-level form fields reached through widgets.
|
||||
pub(super) fields: Vec<ObjRef>,
|
||||
/// Optional content groups copied: (source, destination).
|
||||
pub(super) ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
pub(crate) ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
}
|
||||
|
||||
impl<'a> Copier<'a> {
|
||||
pub(super) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
|
||||
pub(crate) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
|
||||
Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ impl<'a> Copier<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Result<Object, OrganizeError> {
|
||||
pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Result<Object, OrganizeError> {
|
||||
if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) {
|
||||
return Ok(o.clone());
|
||||
}
|
||||
@@ -163,7 +163,7 @@ impl<'a> Copier<'a> {
|
||||
}
|
||||
|
||||
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
|
||||
pub(super) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
for &a in &std::mem::take(&mut self.annots) {
|
||||
let src_dict = dst.get(a).as_dict().cloned();
|
||||
let Some(d) = src_dict else { continue };
|
||||
|
||||
@@ -15,6 +15,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry};
|
||||
|
||||
mod attachments;
|
||||
mod boxes;
|
||||
mod comments;
|
||||
mod dedupe;
|
||||
mod import;
|
||||
mod labels;
|
||||
@@ -27,6 +28,7 @@ mod xmp;
|
||||
|
||||
pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name};
|
||||
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
|
||||
pub use comments::import_comments;
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{
|
||||
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
|
||||
|
||||
@@ -543,7 +543,8 @@ impl PdfCraftApp {
|
||||
}
|
||||
"edit.links_from_urls" => self.links_from_urls(),
|
||||
"edit.remove_links" => self.remove_all_links(),
|
||||
"comment.import" | "form.import_data" => self.import_data_dialog(),
|
||||
"comment.import" => self.import_comments_dialog(),
|
||||
"form.import_data" => self.import_data_dialog(),
|
||||
"comment.stamp" => {
|
||||
self.left = crate::LeftPanel::Tool("stamp");
|
||||
self.reveal_tools();
|
||||
|
||||
@@ -868,10 +868,24 @@ pub(crate) fn strip_pdf(name: &str) -> &str {
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Comments ▸ Import comments / Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text.
|
||||
/// Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text.
|
||||
pub fn import_data_dialog(&mut self) {
|
||||
self.import_dialog(false);
|
||||
}
|
||||
|
||||
/// Comments ▸ Import comments: the files Import data takes, or another PDF to copy the
|
||||
/// comments of.
|
||||
pub fn import_comments_dialog(&mut self) {
|
||||
self.import_dialog(true);
|
||||
}
|
||||
|
||||
fn import_dialog(&mut self, pdf: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut extensions = vec!["xfdf", "fdf", "xml", "xdp", "csv", "txt"];
|
||||
if pdf {
|
||||
extensions.insert(2, "pdf");
|
||||
}
|
||||
let import = |app: &mut Self, path: std::path::PathBuf| match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
@@ -880,11 +894,11 @@ impl PdfCraftApp {
|
||||
Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]),
|
||||
};
|
||||
match self.save_override.clone() {
|
||||
Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()),
|
||||
Some(p) if extensions.iter().any(|e| p.ends_with(&format!(".{e}"))) => import(self, p.into()),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"])
|
||||
.add_filter(tl!("Comment and form data").to_string(), &extensions)
|
||||
.set_title(tl!("Import data").to_string());
|
||||
let target = self.active_ids().map(|(_, id)| id);
|
||||
self.ask_one(crate::pickers::Ask::File(dialog), target, import);
|
||||
@@ -892,7 +906,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify_tr("Importing data arrives on the web with file pickers for data files");
|
||||
{
|
||||
let _ = pdf;
|
||||
self.notify_tr("Importing data arrives on the web with file pickers for data files");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all comments / form data: the format follows the file name's extension.
|
||||
|
||||
@@ -376,6 +376,45 @@ fn export_comments_writes_on_a_later_frame() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_comments_takes_the_comments_of_another_pdf() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
|
||||
];
|
||||
let mut review = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(review.len());
|
||||
review.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = review.len();
|
||||
review.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
review.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
review.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let dir = scratch("import-pdf-comments");
|
||||
let path = dir.join("review.pdf");
|
||||
std::fs::write(&path, review).unwrap();
|
||||
let mut h = harness(&[1]);
|
||||
// Through the save path a test uses in place of the picker, and through the picker.
|
||||
for chosen in [Some(path.to_string_lossy().into_owned()), None] {
|
||||
h.state_mut().save_override = chosen.clone();
|
||||
h.state_mut().pick_override = chosen.is_none().then(|| vec![path.to_string_lossy().into_owned()]);
|
||||
h.state_mut().run_command("comment.import");
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let contents: Vec<Option<String>> = app.session.get(app.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
|
||||
assert_eq!(contents, [Some("from the review".to_string())], "the review's comment is imported once ({chosen:?})");
|
||||
h.state_mut().run_command("edit.undo");
|
||||
h.run_steps(1);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_pages_are_not_deleted_if_the_document_was_edited_while_choosing_the_folder() {
|
||||
let dir = scratch("extract-edited");
|
||||
|
||||
@@ -4264,11 +4264,11 @@ title = "Import comments from another PDF"
|
||||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["comment.import"]
|
||||
tools = ["doc_import_data"]
|
||||
evidence = ["crates/organize/src/comments.rs::comments_of_another_pdf_land_on_the_pages_with_the_same_numbers", "crates/organize/src/comments.rs::a_reply_on_an_earlier_page_reaches_its_comment_by_name", "crates/organize/src/comments.rs::a_comment_keeps_its_layer_and_the_layer_its_visibility", "crates/organize/src/comments.rs::a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays", "crates/organize/src/comments.rs::a_pdf_without_comments_copies_none", "crates/engine/src/tests.rs::importing_a_pdf_copies_its_comments", "crates/ui-egui/tests/it/pickers.rs::import_comments_takes_the_comments_of_another_pdf"]
|
||||
notes = "Import comments accepts a PDF beside XFDF and FDF. Each comment goes onto the page with the same number, at the same place, with its appearance, pop-up and replies; comments on pages past the document's last page are left out. A same-named comment is replaced, as with XFDF and FDF. Not yet: redaction marks are not copied, and a PDF that needs a password is refused."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.summarize"
|
||||
|
||||
Reference in New Issue
Block a user