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>
This commit is contained in:
KiwiKwiandBabaKush authored and GitHub committed 2026-11-10 12:38:55 +01:00
1 parent e243022792
commit 4a83ec87f5
4 files changed
+149 -6

No files matched your search

+2 -2
View File
@@ -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)
}
+50
View File
@@ -89,6 +89,56 @@ thread_local! {
pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = 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<Object>) -> 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<Object>) -> Result<(), pdfcraft_cos::CosError> {
+7 -4
View File
@@ -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)
}
+90
View File
@@ -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());
}