From 4a83ec87f52c384ce22c130f8c87182a40e4f109 Mon Sep 17 00:00:00 2001 From: KiwiKwi <101988920+Kiwi-Kwi@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:38:55 +0700 Subject: [PATCH] fix(annot): drop Apple Markup's stale copy when a page's annotations change (#2829) iOS Markup keeps a private copy of a page's markup (/AAPL:PPK, /AAPL:PPKHash on the page, /AAPL:AKExtras on each annotation) and draws from it. Once /Annots changes, that copy is stale: iOS 26 drew a Markup signature twice. Adding, deleting or answering a comment now drops the copy, records the drop in the repair log, and leaves a refused edit untouched. The change lives in lookup.rs (change_annots), called from create.rs (add) and modify.rs (delete, reply, mark). Each of the four operations has a regression test on an Apple Markup page. Co-authored-by: BabaKush <101988920+BabaKush@users.noreply.github.com> --- crates/annot/src/create.rs | 4 +- crates/annot/src/lookup.rs | 50 +++++++++++++++++++++ crates/annot/src/modify.rs | 11 +++-- crates/annot/src/tests.rs | 90 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index e592b5d934..5af547970e 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,5 +1,5 @@ use super::draw::draw_appearance; -use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; +use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref}; use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box}; use super::types::{ FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, @@ -535,7 +535,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let index = list.len(); list.push(Object::Ref(r)); add_popup(doc, r, popup, &mut list)?; - set_annots(doc, page, list)?; + change_annots(doc, page, list)?; Ok(index) } diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index f8681f899b..788774c428 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -89,6 +89,56 @@ thread_local! { pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations. +const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"]; +const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras"; + +/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS +/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a +/// Markup signature in its own place and again over a signature added here. Without it, Apple +/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the +/// document's repair log (iOS can no longer re-edit those strokes as live ink). +fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> { + let mut dropped = false; + if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) { + doc.update_dict(page, |d| { + for k in APPLE_PAGE_MARKUP { + d.remove(k); + } + })?; + dropped = true; + } + for entry in list { + match entry { + Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => { + d.remove(APPLE_ANNOT_MARKUP); + dropped = true; + } + Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => { + doc.update_dict(*r, |d| { + d.remove(APPLE_ANNOT_MARKUP); + })?; + dropped = true; + } + _ => {} + } + } + if dropped { + doc.note_repair( + "dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink" + .into(), + ); + } + Ok(()) +} + +/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at +/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]). +pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec) -> Result<(), AnnotError> { + drop_apple_markup(doc, page, &mut list)?; + Ok(set_annots(doc, page, list)?) +} + /// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty /// list removes a direct `/Annots`. pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index c478c98a16..ae2222b387 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -4,7 +4,7 @@ use super::create::{ }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value}; -use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots}; +use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, change_annots, is_fill_sign, page_ref, set_annots}; use super::text_style::{self, TextStyle}; use super::types::{ BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, @@ -117,7 +117,10 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { /// go together from either ([`own_deletion`]). pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { let index = deleted_index(doc, page, index)?; - lift_annotation(doc, page, index) + lift_annotation(doc, page, index)?; + // Not in lift_annotation: a drag preview lifts on a throwaway copy and must not log a repair. + let p = page_ref(doc, page)?; + change_annots(doc, p, annots(doc, p)) } /// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without @@ -429,7 +432,7 @@ fn reply( let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } @@ -549,7 +552,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let mut list = annots(doc, p); list.push(Object::Ref(r)); let i = list.len() - 1; - set_annots(doc, p, list)?; + change_annots(doc, p, list)?; Ok(i) } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index f1d2698439..ce5a8c1604 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -5529,3 +5529,93 @@ fn summaries_list_only_usable_quads() { let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap(); assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]); } + +/// A page as iOS Markup saves a signature: its private copy on the page and on the stamp, which +/// also carries a normal appearance. A plain note follows the stamp, at index 1. +fn apple_markup_page() -> Document { + document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] /AAPL:PPK 5 0 R /AAPL:PPKHash 5 0 R >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AP << /N 6 0 R >> /AAPL:AKExtras << /PPKType (draw) >> >>", + b"<< /Length 0 >> +stream + +endstream", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 22 >> +stream +0 0 1 RG 1 1 m 9 9 l S +endstream", + b"<< /Type /Annot /Subtype /Text /Rect [400 400 420 420] /Contents (note) >>", + ]) +} + +/// Apple's copy is gone from the page and from every annotation left on it, and the drop is +/// in the repair log (fidelity: never silent). +fn assert_apple_copy_dropped(doc: &Document) { + assert!(doc.repair_log().iter().any(|l| l.contains("Apple Markup")), "{:?}", doc.repair_log()); + let doc = reopen(doc); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(!page.contains(b"AAPL:PPK") && !page.contains(b"AAPL:PPKHash")); + let all = list(&doc, 0); + assert_eq!(all[0].name(b"Subtype"), Some(&b"Stamp"[..])); + assert!(all.iter().all(|d| !d.contains(b"AAPL:AKExtras"))); +} + +#[test] +fn deleting_from_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + delete_annotation(&mut doc, 0, 1).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn replying_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_reply(&mut doc, 0, 1, "Agreed", "Bob", &meta("r")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn marking_on_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + set_marked(&mut doc, 0, 1, true, "Bob", &meta("m")).unwrap(); + assert_apple_copy_dropped(&doc); +} + +#[test] +fn adding_to_an_apple_markup_page_drops_apples_stale_copy() { + let mut doc = apple_markup_page(); + add_annotation(&mut doc, &new(0, Shape::Note { at: [300.0, 300.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + assert_apple_copy_dropped(&doc); + // The Markup stamp itself stays, with its flags and its appearance, byte for byte. + let doc = reopen(&doc); + let all = list(&doc, 0); + assert_eq!(all[0].get(b"F").and_then(Object::as_int), Some(644)); + assert_eq!(ap_content(&doc, &all[0]), "0 0 1 RG 1 1 m 9 9 l S"); +} + +#[test] +fn a_refused_edit_on_an_apple_markup_page_drops_nothing() { + // An inline, locked Markup stamp: restyling it is refused, so Apple's copy must stay put + // (promoting the inline dictionary to an object is not a change to the markup). + let mut doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AAPL:AKExtras << /PPKType (draw) >> >>] /AAPL:PPK 4 0 R /AAPL:PPKHash 4 0 R >>", + b"<< /Length 0 >> +stream + +endstream", + ]); + assert_eq!( + set_style(&mut doc, 0, 0, StyleChange { color: Some([1.0, 0.0, 0.0]), opacity: None, width: None, endings: None }, &meta("")), + Err(AnnotError::Invalid("the comment is locked".into())) + ); + let page = doc.get(page_refs(&doc).unwrap()[0]); + let page = page.as_dict().unwrap(); + assert!(page.contains(b"AAPL:PPK") && page.contains(b"AAPL:PPKHash")); + assert!(list(&doc, 0)[0].contains(b"AAPL:AKExtras")); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); +}