mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 02:41:31 -06:00
M5: a reply is not drawn on the page or flattened into it (comment.reply) (#5632)
This commit is contained in:
+74
-17
@@ -4,7 +4,8 @@
|
||||
//! Each visible annotation's normal appearance (for check boxes and radio buttons, the one its
|
||||
//! `/AS` selects) is drawn into the page with the matrix of ISO 32000-2 §12.5.5 (Algorithm 8.1:
|
||||
//! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden
|
||||
//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened
|
||||
//! annotations and replies are left alone (a reply is not shown on the page: [`is_reply`]);
|
||||
//! links are kept (they have no ink and stay clickable). Flattened
|
||||
//! comments take their pop-ups and replies with them; flattened widgets are removed from the
|
||||
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
|
||||
//! as it is, so no comment or field value is lost with nothing in its place.
|
||||
@@ -19,6 +20,21 @@ const HIDDEN: i64 = 2;
|
||||
const NO_ROTATE: i64 = 16;
|
||||
const NO_VIEW: i64 = 32;
|
||||
|
||||
/// A reply of type R: a markup annotation with an `/IRT` to the annotation it answers and an
|
||||
/// `/RT` other than `/Group` (R is the default, so a missing, unknown or malformed `/RT` reads as
|
||||
/// R, as the comment list reads it). It is shown in its comment's thread, never on the page
|
||||
/// ("Interactive PDF processors shall not display replies to an annotation individually", ISO
|
||||
/// 32000-2 Table 172), so flattening neither draws it nor builds it an appearance; it goes with
|
||||
/// its comment. A `/RT /Group` member is part of its group's look and is flattened as any other
|
||||
/// annotation. `/IRT` is a markup annotation's entry: a widget, link or pop-up with one is no
|
||||
/// reply, and is flattened (or kept) as it would be without. Keep in step with hayro-interpret's
|
||||
/// PdfCraft patch (81).
|
||||
fn is_reply(doc: &Document, d: &Dict) -> bool {
|
||||
!matches!(d.name(b"Subtype"), Some(b"Widget" | b"Link" | b"Popup"))
|
||||
&& d.get(b"IRT").is_some_and(|o| doc.resolve(o).as_dict().is_some())
|
||||
&& d.get(b"RT").map(|o| doc.resolve(o)).as_deref().and_then(Object::as_name) != Some(b"Group")
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
|
||||
let o = doc.resolve(o?);
|
||||
o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect()
|
||||
@@ -319,7 +335,8 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
|
||||
}
|
||||
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) {
|
||||
// A reply is never drawn into the page (`is_reply`), so its text can't bake in a `?`.
|
||||
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) || is_reply(doc, &d) {
|
||||
continue;
|
||||
}
|
||||
let widget = d.name(b"Subtype") == Some(b"Widget");
|
||||
@@ -346,13 +363,60 @@ fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retain
|
||||
continue;
|
||||
}
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
|
||||
// A reply is not shown on the page (`is_reply`), so it gets no appearance either.
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, &d) || appearance(doc, &d).is_some() {
|
||||
continue;
|
||||
}
|
||||
// A subtype this build cannot draw fails here and is kept below, so the comment is not
|
||||
// deleted with nothing drawn in its place. (Text the standard fonts can't draw was
|
||||
// refused before anything changed: `undrawn_on`.)
|
||||
let _ = pdfcraft_annot::set_appearance(doc, r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds to `gone` and `gone_inline` the pop-ups and replies of what is in them, at any depth of
|
||||
/// the thread and in any order of `/Annots`, passing by the entries `stays` keeps. What points at
|
||||
/// each annotation (a pop-up by `/Parent`, a reply by `/IRT`) is gathered once, then followed
|
||||
/// from what went. `/Annots` should hold references (ISO 32000-2 Table 31), but a pop-up or
|
||||
/// reply written inline still goes with what it points at; nothing can point at it in turn.
|
||||
fn take_threads(
|
||||
doc: &Document,
|
||||
list: &[Object],
|
||||
gone: &mut HashSet<ObjRef>,
|
||||
gone_inline: &mut HashSet<usize>,
|
||||
stays: impl Fn(usize, &Object) -> bool,
|
||||
) {
|
||||
let mut pointing: HashMap<ObjRef, Vec<ObjRef>> = HashMap::new();
|
||||
let mut pointing_inline: Vec<(ObjRef, usize)> = Vec::new();
|
||||
for (k, entry) in list.iter().enumerate() {
|
||||
if stays(k, entry) {
|
||||
continue;
|
||||
}
|
||||
let r = entry.as_ref();
|
||||
let obj = doc.resolve(entry);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let popup_of = d.reference(b"Parent").filter(|_| d.name(b"Subtype") == Some(b"Popup"));
|
||||
// Only a reply goes with what it answers: a `/RT /Group` member was drawn (and went) or
|
||||
// stays, hidden or undrawable, as any annotation.
|
||||
let reply_to = d.reference(b"IRT").filter(|_| is_reply(doc, d));
|
||||
for p in popup_of.into_iter().chain(reply_to) {
|
||||
match r {
|
||||
Some(r) => pointing.entry(p).or_default().push(r),
|
||||
None => pointing_inline.push((p, k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut follow: Vec<ObjRef> = gone.iter().copied().collect();
|
||||
while let Some(p) = follow.pop() {
|
||||
for &r in pointing.get(&p).into_iter().flatten() {
|
||||
if gone.insert(r) {
|
||||
follow.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
gone_inline.extend(pointing_inline.into_iter().filter(|(p, _)| gone.contains(p)).map(|(_, k)| k));
|
||||
}
|
||||
|
||||
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
|
||||
/// success, so a page refused part way leaves the pages before it as they were.
|
||||
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
@@ -431,7 +495,8 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
continue;
|
||||
}
|
||||
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 {
|
||||
// Not on the page, so not drawn into it; a reply goes with its comment (below).
|
||||
if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, d) {
|
||||
continue;
|
||||
}
|
||||
let rect = nums(doc, d.get(b"Rect")).filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
|
||||
@@ -491,19 +556,11 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
continue;
|
||||
}
|
||||
// Pop-ups and replies of what was flattened go too, except a reply that stayed because
|
||||
// it could not be drawn: its content would go with nothing in its place.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if undrawn.contains(&r) || retained.refs.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| gone.contains(&t));
|
||||
if points_at(b"Parent") && d.name(b"Subtype") == Some(b"Popup") || points_at(b"IRT") {
|
||||
gone.insert(r);
|
||||
}
|
||||
}
|
||||
// it could not be drawn (its content would go with nothing in its place) and what is
|
||||
// retained with its thread.
|
||||
take_threads(doc, &list, &mut gone, &mut gone_inline, |k, entry| {
|
||||
entry.as_ref().is_some_and(|r| undrawn.contains(&r)) || retained.contains(k, entry)
|
||||
});
|
||||
let kept: Vec<Object> = list
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
+204
-5
@@ -802,10 +802,12 @@ fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() {
|
||||
assert_eq!(annots, Some(1), "the widget stays");
|
||||
}
|
||||
|
||||
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
|
||||
/// is flattened; the comment's pop-up still goes with it.
|
||||
/// #1794, #5631: a reply this builder cannot draw goes with the comment it replies to, as its
|
||||
/// pop-up does. A reply of type R is not shown on the page (ISO 32000-2 Table 172), so it has no
|
||||
/// ink of its own to keep; before #5631 it stayed, the orphan of a comment that was gone. (A
|
||||
/// `/RT /Group` member that cannot be drawn still stays: it is drawn on the page.)
|
||||
#[test]
|
||||
fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
|
||||
fn flattening_takes_an_undrawable_reply_with_its_comment() {
|
||||
let page = stream("", " ");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
@@ -817,11 +819,208 @@ fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream",
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be");
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the reply is not");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert!(!p.dict.contains(b"Annots"), "the comment, its reply and its pop-up are gone");
|
||||
}
|
||||
|
||||
/// #5631: flattening draws no reply of type R into the page, whatever its appearance, and builds
|
||||
/// none for one without (a note reply would get an icon); every reply goes with its comment, a
|
||||
/// reply to a reply too, even listed before the reply it answers. A `/RT /Group` member is
|
||||
/// drawn as any annotation.
|
||||
#[test]
|
||||
fn flattening_draws_no_reply_and_takes_the_thread_with_its_comment() {
|
||||
let page = stream("", " ");
|
||||
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
|
||||
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
// The reply to the reply (8) is listed before the reply it answers (6).
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 8 0 R 6 0 R 7 0 R 9 0 R] >>",
|
||||
&page,
|
||||
// The comment (blue), its reply (red), its pop-up, a reply to the reply with no
|
||||
// appearance, and a member of its group (green).
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 10 0 R >> /Popup 7 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /AP << /N 11 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [300 500 400 600] /Parent 5 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply to the reply) /IRT 6 0 R /RT /R >>",
|
||||
"<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 12 0 R >> >>",
|
||||
&blue,
|
||||
&red,
|
||||
&green,
|
||||
]);
|
||||
let n = flatten(&mut doc, &[0], true, false).unwrap();
|
||||
assert_eq!(n, 2, "the comment and the group member are drawn; the replies are not");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert!(!p.dict.contains(b"Annots"), "the whole thread, its pop-up and the group member are gone");
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
|
||||
drawn.sort_unstable();
|
||||
assert_eq!(drawn, [10, 12], "the comment's and the member's appearances; not the reply's, and none built for the other");
|
||||
}
|
||||
|
||||
/// #5632 review: a reply's text never blocks flattening (a reply is not drawn into the page, so
|
||||
/// it can't bake in a `?`), and a `/RT /Group` member that flattening skipped (Hidden here)
|
||||
/// stays when its primary is flattened: only replies go with what they answer.
|
||||
#[test]
|
||||
fn flattening_skips_replies_in_the_question_mark_check_and_keeps_skipped_group_members() {
|
||||
let page = stream("", " ");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Square /Rect [10 10 60 40] /F 4 /AP << /N 8 0 R >> >>",
|
||||
// A text-box reply whose text Helvetica can't draw, with no appearance.
|
||||
"<< /Type /Annot /Subtype /FreeText /Rect [10 50 150 80] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /IRT 5 0 R >>",
|
||||
// A hidden member of the square's group.
|
||||
"<< /Type /Annot /Subtype /Circle /Rect [70 10 100 40] /F 6 /IRT 5 0 R /RT /Group /AP << /N 8 0 R >> >>",
|
||||
&stream("/Type /XObject /Subtype /Form /BBox [0 0 50 30]", "0 0 1 rg 0 0 50 30 re f"),
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "flattened, the square drawn");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
|
||||
assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone");
|
||||
assert_eq!(left, [7], "the reply went with the square; the hidden group member stays");
|
||||
}
|
||||
|
||||
/// #5632 review: a thread three replies deep, listed in `/Annots` in reverse (the deepest reply
|
||||
/// first, the comment last), goes whole with its comment, none of the replies drawn; a reply to a
|
||||
/// `/RT /Group` member goes with that member, which is drawn.
|
||||
#[test]
|
||||
fn flattening_takes_a_thread_three_deep_in_reverse_order_and_a_group_members_reply() {
|
||||
let page = stream("", " ");
|
||||
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
|
||||
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [8 0 R 7 0 R 6 0 R 5 0 R 9 0 R 10 0 R] >>",
|
||||
&page,
|
||||
// The comment (blue), and three replies, each to the one before, all with appearances (red).
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 11 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply 1) /IRT 5 0 R /AP << /N 12 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply 2) /IRT 6 0 R /RT /R /AP << /N 12 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [115 485 135 503] /Contents (Reply 3) /IRT 7 0 R /AP << /N 12 0 R >> >>",
|
||||
// A member of the comment's group (green), and a reply to the member.
|
||||
"<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 13 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /Contents (Reply to the member) /IRT 9 0 R /AP << /N 12 0 R >> >>",
|
||||
&blue,
|
||||
&red,
|
||||
&green,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "the comment and the group member are drawn; no reply is");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert!(!p.dict.contains(b"Annots"), "the three replies and the member's reply go with what they answer");
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
|
||||
drawn.sort_unstable();
|
||||
assert_eq!(drawn, [11, 13], "the comment's and the member's appearances, not the replies'");
|
||||
}
|
||||
|
||||
/// #5632 review: `/Annots` should hold references (ISO 32000-2 Table 31), but a reply or pop-up
|
||||
/// written inline in it still goes with the comment it points at, as a referenced one does. One
|
||||
/// pointing at a comment that stays, stays.
|
||||
#[test]
|
||||
fn flattening_takes_inline_replies_and_popups_with_their_comment() {
|
||||
let page = stream("", " ");
|
||||
let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R \
|
||||
<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Inline reply) /IRT 5 0 R >> \
|
||||
<< /Type /Annot /Subtype /Popup /Rect [200 400 300 500] /Parent 5 0 R >> \
|
||||
<< /Type /Annot /Subtype /Text /F 28 /Rect [105 395 125 413] /Contents (Reply to a hidden note) /IRT 6 0 R >> 6 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 7 0 R >> >>",
|
||||
// Hidden: not flattened, so it stays, and so does its inline reply.
|
||||
"<< /Type /Annot /Subtype /Text /F 30 /Rect [100 400 120 418] /Contents (Hidden note) /AP << /N 7 0 R >> >>",
|
||||
&blue,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let annots = doc.resolve(p.dict.get(b"Annots").unwrap());
|
||||
let left: Vec<String> = annots
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| doc.resolve(a).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default())
|
||||
.collect();
|
||||
assert_eq!(left, ["Reply to a hidden note", "Hidden note"], "the inline reply and pop-up went with the comment");
|
||||
}
|
||||
|
||||
/// #5632 review: replies whose `/IRT` runs in a loop (two replying to each other, one replying to
|
||||
/// itself, and a square and a note each answering the other) end the flattening. None of them is
|
||||
/// drawn: each is a reply. Those that answer nothing flattened stay; the comment beside them is
|
||||
/// flattened and its reply goes with it.
|
||||
#[test]
|
||||
fn flattening_ends_on_replies_whose_irt_loops() {
|
||||
let page = stream("", " ");
|
||||
let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>",
|
||||
&page,
|
||||
// A comment and its reply.
|
||||
"<< /Type /Annot /Subtype /Square /F 4 /Rect [100 500 120 518] /AP << /N 12 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /IRT 5 0 R /AP << /N 12 0 R >> >>",
|
||||
// Two replies to each other.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [200 500 220 518] /IRT 8 0 R /AP << /N 12 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /IRT 7 0 R /AP << /N 12 0 R >> >>",
|
||||
// A reply to itself.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [300 500 320 518] /IRT 9 0 R /AP << /N 12 0 R >> >>",
|
||||
// A square and a note each answering the other: both are replies.
|
||||
"<< /Type /Annot /Subtype /Square /F 4 /Rect [400 500 420 518] /IRT 11 0 R /AP << /N 12 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [405 495 425 513] /IRT 10 0 R /AP << /N 12 0 R >> >>",
|
||||
&blue,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
|
||||
assert_eq!(left, [7, 8, 9, 10, 11], "the comment's reply went with it; the looping replies stay");
|
||||
}
|
||||
|
||||
/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list and the
|
||||
/// page read it, so replies with an unknown name or a string there are neither drawn nor kept
|
||||
/// once their comment is flattened. A form field with a stray `/IRT` is no reply: it is flattened
|
||||
/// as a field.
|
||||
#[test]
|
||||
fn flattening_reads_an_unknown_or_malformed_rt_as_a_reply_and_a_widget_with_irt_as_a_field() {
|
||||
let page = stream("", " ");
|
||||
let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f"));
|
||||
let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0"));
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 9 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /RT /Answer /AP << /N 10 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply) /IRT 5 0 R /RT (Group) /AP << /N 10 0 R >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /F 4 /Rect [200 500 220 518] /IRT 5 0 R /AP << /N 11 0 R >> >>",
|
||||
&blue,
|
||||
&red,
|
||||
&green,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, true).unwrap(), 2, "the comment and the field are drawn; neither reply is");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert!(!p.dict.contains(b"Annots"), "both replies went with the comment, and the field was flattened");
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let mut drawn: Vec<u32> = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect();
|
||||
drawn.sort_unstable();
|
||||
assert_eq!(drawn, [9, 11], "the comment's and the field's appearances, not the replies'");
|
||||
}
|
||||
|
||||
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
|
||||
|
||||
@@ -10529,6 +10529,101 @@ fn content_after_an_inline_image_holding_ei_is_still_read() {
|
||||
assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}");
|
||||
}
|
||||
|
||||
/// #5631: a reply of type R (`/IRT`, no `/RT` or `/RT /R`) is not drawn on the page, whatever
|
||||
/// its appearance: ISO 32000-2 Table 172, "shall not display replies to an annotation
|
||||
/// individually". The comment it answers draws, and so does a `/RT /Group` member.
|
||||
#[test]
|
||||
fn replies_are_not_drawn_but_their_comment_and_group_members_are() {
|
||||
let form = |rgb: &str| {
|
||||
let content = format!("{rgb} rg 0 0 20 18 re f");
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
};
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(),
|
||||
// The comment: blue.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(),
|
||||
// A reply with no /RT, and one with /RT /R, to it and to that reply: red.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /IRT 5 0 R /RT /R /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(),
|
||||
// A member of the comment's group: green.
|
||||
"<< /Type /Annot /Subtype /Square /F 4 /IRT 4 0 R /RT /Group /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(),
|
||||
form("0 0 1"),
|
||||
form("1 0 0"),
|
||||
form("0 1 0"),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
// The middle of each annotation, top-left based at scale 1.
|
||||
let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() };
|
||||
let (comment, reply, reply_r, member) = (px(20), px(60), px(100), px(140));
|
||||
assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}");
|
||||
assert!(reply.iter().all(|v| *v > 240), "a reply with no /RT is not drawn: {reply:?}");
|
||||
assert!(reply_r.iter().all(|v| *v > 240), "a reply with /RT /R is not drawn: {reply_r:?}");
|
||||
assert!(member[1] > 200 && member[0] < 60, "a group member draws: {member:?}");
|
||||
}
|
||||
|
||||
/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list reads
|
||||
/// it, so a reply with an unknown name or a string there isn't drawn either. `/IRT` is a markup
|
||||
/// annotation's entry: a form field carrying one still draws.
|
||||
#[test]
|
||||
fn an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws() {
|
||||
let form = |rgb: &str| {
|
||||
let content = format!("{rgb} rg 0 0 20 18 re f");
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
};
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(),
|
||||
// The comment: blue.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(),
|
||||
// Replies with an unknown /RT name and with a string there: red.
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT /Answer /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(),
|
||||
"<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT (Group) /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(),
|
||||
// A form field with a stray /IRT: green.
|
||||
"<< /Type /Annot /Subtype /Widget /F 4 /IRT 4 0 R /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(),
|
||||
form("0 0 1"),
|
||||
form("1 0 0"),
|
||||
form("0 1 0"),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() };
|
||||
let (comment, unknown, string, widget) = (px(20), px(60), px(100), px(140));
|
||||
assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}");
|
||||
assert!(unknown.iter().all(|v| *v > 240), "a reply with an unknown /RT is not drawn: {unknown:?}");
|
||||
assert!(string.iter().all(|v| *v > 240), "a reply with a string /RT is not drawn: {string:?}");
|
||||
assert!(widget[1] > 200 && widget[0] < 60, "a form field with /IRT draws: {widget:?}");
|
||||
}
|
||||
|
||||
/// `/Crypt` has nothing to undo, so a chain naming it thousands of times costs no copy of the data
|
||||
/// for each. A chain of filters that does work has its own cost, which this isn't about.
|
||||
#[test]
|
||||
|
||||
@@ -4070,8 +4070,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.list"]
|
||||
tools = ["comment_reply"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"]
|
||||
notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions"
|
||||
evidence = ["crates/render/src/raster/tests.rs::replies_are_not_drawn_but_their_comment_and_group_members_are", "crates/edit/src/tests.rs::flattening_draws_no_reply_and_takes_the_thread_with_its_comment", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::ctrl_enter_posts_a_reply_and_shift_enter_adds_a_line", "crates/annot/src/tests.rs::replies_and_status_thread_onto_the_parent", "crates/ui-egui/tests/it/comments.rs::one_reply_is_deleted_from_its_menu_and_the_thread_stays"]
|
||||
notes = "multiline reply field: Enter or Ctrl+Enter (also Command+Enter) posts, Shift+Enter adds a newline; empty replies are not posted. A reply (of type R) is shown in its comment's thread, never on the page, whatever its appearance (ISO 32000-2 Table 172), and flattening neither draws nor bakes it: it goes with its comment; a `/RT /Group` member still draws. On the selected card each reply has its own \"…\" menu (Edit, Copy text, Delete); a Replace Text caret, half of its comment, has none. Missing: Set status on a reply from its menu, @mentions"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.set-status"
|
||||
@@ -4430,8 +4430,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.flatten"]
|
||||
tools = ["doc_flatten"]
|
||||
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"]
|
||||
notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)"
|
||||
evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_takes_an_undrawable_reply_with_its_comment", "crates/edit/src/tests.rs::flattening_draws_no_reply_and_takes_the_thread_with_its_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"]
|
||||
notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed with their comment, at any depth of the thread (a reply is never drawn into the page, ISO 32000-2 Table 172, nor given an appearance to draw); a comment whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted, and so do its replies; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.migrate"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+14
-1
@@ -18,7 +18,7 @@ use crate::x_object::{
|
||||
use hayro_syntax::content::TypedIter;
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::dict::keys::{
|
||||
ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE,
|
||||
ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, IRT, MCID, N, OC, QUADPOINTS, RECT, RT, SUBTYPE,
|
||||
};
|
||||
use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream};
|
||||
use hayro_syntax::page::{ContentReader, Page, Resources, Rotation};
|
||||
@@ -229,6 +229,19 @@ pub fn interpret_page<'a>(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch (81): a reply of type R (a markup annotation with an `/IRT` to the
|
||||
// annotation it answers and an `/RT` other than `/Group`; R is the default, so a
|
||||
// missing, unknown or malformed `/RT` reads as R) is not drawn, whatever its
|
||||
// appearance or flags: "Interactive PDF processors shall not display replies to an
|
||||
// annotation individually but together in the form of threaded comments" (ISO 32000-2
|
||||
// Table 172). A `/RT /Group` member is part of its group's look and still draws.
|
||||
// `/IRT` is a markup annotation's entry: a widget, link or pop-up with one still draws.
|
||||
if !matches!(annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link" | b"Popup"))
|
||||
&& annot.get::<Dict<'_>>(IRT).is_some()
|
||||
&& annot.get::<hayro_syntax::object::Name<'_>>(RT).as_deref() != Some(b"Group")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// PdfCraft patch: "Hide all comments" keeps widgets and links.
|
||||
if context.settings.hide_comments
|
||||
&& !matches!(annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link"))
|
||||
|
||||
Reference in New Issue
Block a user