M4: preserve shared Type 3 glyph resources during extraction (#1500)

Co-authored-by: MMexicann <[email protected]>
Co-authored-by: OpenAI Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 02:34:12 -04:00
1 parent 13a921ae4a
commit 5ebb53a4a4
4 files changed
+345 -14

No files matched your search

+2
View File
@@ -2,6 +2,7 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -146,6 +147,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged.
+131 -13
View File
@@ -14,6 +14,8 @@ use crate::OrganizeError;
/// Form XObjects nested deeper than this are a broken file, not a reason to hang.
const MAX_FORM_DEPTH: u8 = 8;
/// Count every resource object and direct child; a graph beyond this is left unpruned.
const MAX_GLYPH_RESOURCE_WORK: usize = 10_000;
/// The dictionary a set of drawn names belongs to.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
@@ -42,6 +44,12 @@ struct Pruner<'a> {
/// past the depth limit, Type 3 glyphs drawing through them). They are left untouched:
/// pruning must never drop an XObject something still draws.
unsure: HashSet<XKey>,
/// An overlarge glyph-resource graph is not safe to prune at all.
glyph_budget_exhausted: bool,
glyph_work_left: usize,
glyph_seen: HashSet<ObjRef>,
/// An indirect font's explicit resources have one owner regardless of which page uses it.
glyph_fonts: HashSet<ObjRef>,
}
impl Pruner<'_> {
@@ -52,14 +60,106 @@ impl Pruner<'_> {
}
}
/// True when `resources` lists a Type 3 font without its own `/Resources`: its glyph
/// procedures draw through these resources, and they are not followed here.
fn has_inheriting_type3(&self, resources: &Dict) -> bool {
let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return false };
let Some(fonts) = fonts.as_dict() else { return false };
fonts
.iter()
.any(|(_, f)| self.doc.resolve(f).as_dict().is_some_and(|fd| fd.name(b"Subtype") == Some(b"Type3") && fd.get(b"Resources").is_none()))
fn glyph_work(&mut self, count: usize) -> bool {
if !self.glyph_budget_exhausted
&& let Some(left) = self.glyph_work_left.checked_sub(count)
{
self.glyph_work_left = left;
return true;
}
self.glyph_budget_exhausted = true;
false
}
/// Walk one object's direct children without cloning them or recursing. Referenced objects
/// go on a separate work list, so their borrowed dictionaries stay alive during this pass.
fn protect_glyph_tree(&mut self, object: &Object, referenced: &mut Vec<ObjRef>) {
if !self.glyph_work(1) {
return;
}
let mut pending = vec![object];
while let Some(object) = pending.pop() {
match object {
Object::Ref(r) => {
// A glyph's forms, patterns and soft masks can share either the XObject
// dictionary itself or a resource owner with content we do interpret.
self.unsure.insert(XKey::XObject(*r));
self.unsure.insert(XKey::Resources(*r));
self.unsure.insert(XKey::Page(*r));
if self.glyph_seen.insert(*r) {
referenced.push(*r);
}
}
Object::Array(array) => {
if !self.glyph_work(array.len()) {
return;
}
pending.extend(array);
}
Object::Dict(_) | Object::Stream(_) => {
if let Some(dict) = object.as_dict() {
if !self.glyph_work(dict.len()) {
return;
}
pending.extend(dict.iter().map(|(_, value)| value));
}
}
_ => {}
}
}
}
/// Glyph programs are not interpreted here. Keep every resource owner reachable from their
/// resource graph, including nested forms, patterns and soft masks. Data bytes are never
/// decoded; references are visited once and all direct children count against the budget.
fn protect_glyph_object(&mut self, object: &Object) {
let mut referenced = Vec::new();
self.protect_glyph_tree(object, &mut referenced);
while !self.glyph_budget_exhausted {
let Some(r) = referenced.pop() else { break };
let object = self.doc.get(r);
self.protect_glyph_tree(&object, &mut referenced);
}
}
fn protect_type3_resources(&mut self, resources: &Dict, owner: Owner) {
if self.glyph_budget_exhausted {
return;
}
let Some(fonts) = resources.get(b"Font").map(|f| self.doc.resolve(f)) else { return };
let Some(fonts) = fonts.as_dict() else { return };
// Bound discovery too, but don't spend the glyph-graph budget repeatedly examining an
// ordinary shared font catalog on every page of a large document.
if fonts.len() > MAX_GLYPH_RESOURCE_WORK {
self.glyph_budget_exhausted = true;
return;
}
for (_, font) in fonts.iter() {
if self.glyph_budget_exhausted {
return;
}
let font_ref = font.as_ref();
let resolved = self.doc.resolve(font);
let Some(font) = resolved.as_dict().filter(|f| f.name(b"Subtype") == Some(b"Type3")) else { continue };
if let Some(glyph_resources) = font.get(b"Resources") {
if let Some(r) = font_ref
&& !self.glyph_fonts.insert(r)
{
continue;
}
self.protect_glyph_object(glyph_resources);
} else {
self.distrust(resources, owner);
// This root dictionary is already owned by the content caller. Only its
// referenced descendants can share additional owners elsewhere in the file.
for (_, value) in resources.iter() {
self.protect_glyph_object(value);
if self.glyph_budget_exhausted {
return;
}
}
}
}
}
/// The `/XObject` dictionary of `resources`, with the key its drawn names belong to.
@@ -77,10 +177,11 @@ impl Pruner<'_> {
/// Record every XObject name `data` draws with `Do`, descending into form XObjects.
fn content(&mut self, data: &[u8], resources: &Dict, owner: Owner, depth: u8) {
self.protect_type3_resources(resources, owner);
let Some((xobjects, key)) = self.xobjects(resources, owner) else {
return;
};
if depth > MAX_FORM_DEPTH || self.has_inheriting_type3(resources) {
if depth > MAX_FORM_DEPTH {
self.unsure.insert(key);
return;
}
@@ -113,7 +214,10 @@ impl Pruner<'_> {
match s.decoded() {
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
// What the form draws is unknown: keep everything it could reach.
Err(_) => self.distrust(&inner, inner_owner),
Err(_) => {
self.protect_type3_resources(&inner, inner_owner);
self.distrust(&inner, inner_owner);
}
}
}
}
@@ -202,7 +306,15 @@ fn filter_at(doc: &mut Document, key: XKey, names: &HashSet<Vec<u8>>) -> Result<
/// Drop the page-resource entries the document's pages never draw. Returns how many were removed.
pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, OrganizeError> {
let pages = crate::walk(doc)?;
let mut pruner = Pruner { doc, used: HashMap::new(), unsure: HashSet::new() };
let mut pruner = Pruner {
doc,
used: HashMap::new(),
unsure: HashSet::new(),
glyph_budget_exhausted: false,
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
glyph_seen: HashSet::new(),
glyph_fonts: HashSet::new(),
};
for (page, attrs) in &pages {
let Some(res) = attrs.get(b"Resources") else { continue };
let Some((res_dict, owner)) = resolve_resources(pruner.doc, res, *page) else { continue };
@@ -211,10 +323,16 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
match content_bytes(pruner.doc, &contents) {
Some(data) if data.is_empty() => {}
Some(data) => pruner.content(&data, &res_dict, owner, 0),
None => pruner.distrust(&res_dict, owner),
None => {
pruner.protect_type3_resources(&res_dict, owner);
pruner.distrust(&res_dict, owner);
}
}
}
let Pruner { used, unsure, .. } = pruner;
let Pruner { used, unsure, glyph_budget_exhausted, .. } = pruner;
if glyph_budget_exhausted {
return Ok(0);
}
let mut removed = 0;
for (key, names) in used {
if !unsure.contains(&key) {
+209
View File
@@ -1847,6 +1847,215 @@ fn extract_keeps_images_type3_glyphs_may_draw() {
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "pruning must not run through Type 3 resources");
}
/// The font has its own resources, but its XObject dictionary is also used by the page.
/// Looking only at page `Do` operators must not discard the image painted by a glyph.
fn doc_type3_shared_xobjects(font_resources: &str) -> Document {
let objects: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources 5 0 R >>".into(),
raw("q 30 0 0 30 10 10 cm /ImA Do Q BT /T3 50 Tf 50 80 Td (A) Tj ET"),
"<< /XObject 6 0 R /Font << /T3 7 0 R >> >>".into(),
"<< /ImA 9 0 R /ImB 10 0 R /Unused 11 0 R >>".into(),
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /CharProcs << /A 8 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [1] /Resources {font_resources} >>"
),
raw("1 0 0 0 1 1 d1 /ImB Do"),
image(1, 1, 1),
image(1, 1, 1),
image(1, 1, 2000),
];
open(build(&objects, "/Root 1 0 R"))
}
#[test]
fn extract_keeps_shared_xobjects_used_by_type3_own_resources() {
for resources in ["<< /XObject 6 0 R >>", "5 0 R"] {
let src = doc_type3_shared_xobjects(resources);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let names = xobject_names(&part, 0);
assert!(names.iter().any(|n| n.as_slice() == b"ImB"), "the Type 3 glyph's image must survive extraction: {resources}");
}
}
#[test]
fn extract_still_prunes_page_xobjects_when_type3_resources_are_separate() {
let src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "only the page's unused entries are removed");
let resources = part.resolve(page_dict(&part, 0).get(b"Resources").unwrap());
let fonts = part.resolve(resources.as_dict().unwrap().get(b"Font").unwrap());
let font = part.resolve(fonts.as_dict().unwrap().get(b"T3").unwrap());
let resources = part.resolve(font.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = part.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
assert!(xobjects.as_dict().unwrap().contains(b"ImB"), "the separate glyph resource survives the full rewrite");
}
fn doc_type3_glyph_form_with_shared_resources(cycle: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< >>");
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Ref(ObjRef::new(6, 0)));
let mut form = pdfcraft_cos::Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(1), Object::Int(1)]));
form.set(b"Resources".to_vec(), Object::Dict(resources));
let form = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(form, b"/ImB Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(form));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
doc.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
doc.set(ObjRef::new(8, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"1 0 0 0 1 1 d1 /Fm Do".to_vec())));
if cycle {
doc.update_dict(ObjRef::new(6, 0), |xobjects| xobjects.set(b"Again".to_vec(), Object::Ref(form))).unwrap();
}
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_shared_resources_reached_through_a_type3_glyph_form() {
for cycle in [false, true] {
let src = doc_type3_glyph_form_with_shared_resources(cycle);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "a glyph's nested form draws ImB; resource cycle: {cycle}");
}
}
#[test]
fn extract_keeps_an_indirect_resource_owner_shared_with_a_type3_font() {
let mut src = doc_type3_shared_xobjects("5 0 R");
let xobjects = src.get(ObjRef::new(6, 0)).as_dict().unwrap().clone();
src.update_dict(ObjRef::new(5, 0), |resources| resources.set(b"XObject".to_vec(), Object::Dict(xobjects))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "the resource owner itself is shared, with inline XObjects");
}
#[test]
fn extract_skips_pruning_when_direct_glyph_resource_children_exceed_the_budget() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
let font = src.get(ObjRef::new(7, 0));
let mut resources = font.as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
let mut unknown = vec![Object::Null; 20_000];
unknown.push(Object::Ref(ObjRef::new(6, 0)));
resources.set(b"Unknown".to_vec(), Object::Array(unknown));
src.update_dict(ObjRef::new(7, 0), |font| font.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(xobject_names(&part, 0), xobject_names(&src, 0), "an incomplete glyph resource walk cannot authorize pruning any owners");
}
fn doc_type3_resources_of_undecodable_content(form: bool) -> Document {
let mut doc = doc_type3_shared_xobjects("<< /XObject 6 0 R >>");
doc.update_dict(ObjRef::new(5, 0), |resources| {
resources.remove(b"Font");
})
.unwrap();
doc.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let mut fonts = pdfcraft_cos::Dict::new();
fonts.set(b"T3".to_vec(), Object::Ref(ObjRef::new(7, 0)));
let mut glyph_resources = pdfcraft_cos::Dict::new();
glyph_resources.set(b"Font".to_vec(), Object::Dict(fonts));
let mut unreadable = pdfcraft_cos::Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
if form {
unreadable.set(b"Type".to_vec(), Object::name("XObject"));
unreadable.set(b"Subtype".to_vec(), Object::name("Form"));
unreadable.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(200)]));
unreadable.set(b"Resources".to_vec(), Object::Dict(glyph_resources.clone()));
}
let unreadable = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(unreadable, b"BT /T3 50 Tf 50 80 Td (A) Tj ET".to_vec())));
let (contents, resources) = if form {
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/Fm Do".to_vec())));
let mut xobjects = pdfcraft_cos::Dict::new();
xobjects.set(b"Fm".to_vec(), Object::Ref(unreadable));
let mut resources = pdfcraft_cos::Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
(contents, resources)
} else {
(unreadable, glyph_resources)
};
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(ObjRef::new(2, 0)));
page.set(b"Contents".to_vec(), Object::Ref(contents));
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(page);
doc.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(page)]));
pages.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
full_roundtrip(&doc)
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_page() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(false), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable page's font shares a different page's XObject dictionary");
let contents = part.resolve(page_dict(&part, 1).get(b"Contents").unwrap());
let Object::Stream(contents) = &*contents else { panic!("content stream") };
assert_eq!(contents.dict.name(b"Filter"), Some(b"NoSuchFilter".as_slice()));
assert_eq!(contents.raw.as_ref(), b"BT /T3 50 Tf 50 80 Td (A) Tj ET");
}
#[test]
fn extract_keeps_type3_glyph_resources_of_an_undecodable_form() {
let part = full_roundtrip(&extract_pages(&doc_type3_resources_of_undecodable_content(true), &[0, 1]).unwrap());
assert!(xobject_names(&part, 0).iter().any(|n| n.as_slice() == b"ImB"), "an unreadable form's font shares a different page's XObject dictionary");
}
#[test]
fn extract_still_prunes_separate_resources_when_many_pages_share_a_type3_font() {
let mut src = doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>");
src.set(ObjRef::new(4, 0), Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"/ImA Do".to_vec())));
let page = src.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let mut kids = vec![Object::Ref(ObjRef::new(3, 0))];
for _ in 1..4_000 {
kids.push(Object::Ref(src.add(page.clone())));
}
src.update_dict(ObjRef::new(2, 0), |pages| {
pages.set(b"Kids".to_vec(), Object::Array(kids));
pages.set(b"Count".to_vec(), Object::Int(4_000));
})
.unwrap();
let src = full_roundtrip(&src);
let part = full_roundtrip(&extract_pages(&src, &(0..4_000).collect::<Vec<_>>()).unwrap());
assert_eq!(xobject_names(&part, 0), [b"ImA".to_vec()], "one shared font's direct resource graph is protected once");
}
#[test]
#[ignore = "requires the external Poppler pdftoppm oracle"]
fn extract_type3_shared_xobjects_matches_poppler_pixels() {
let dir = std::env::temp_dir().join(format!("pdfcraft-type3-extract-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let fixtures = [
doc_type3_shared_xobjects("<< /XObject 6 0 R >>"),
doc_type3_shared_xobjects("5 0 R"),
doc_type3_shared_xobjects("<< /XObject << /ImB 10 0 R >> >>"),
doc_type3_glyph_form_with_shared_resources(false),
doc_type3_glyph_form_with_shared_resources(true),
];
for (i, src) in fixtures.into_iter().enumerate() {
let part = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
let render = |doc: &Document, name: &str| {
let path = dir.join(format!("{i}-{name}.pdf"));
std::fs::write(&path, write_full(doc, &SaveOptions::default()).unwrap()).unwrap();
let output = std::process::Command::new("pdftoppm").args(["-singlefile", "-r", "72", "-gray"]).arg(path).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
};
let before = render(&src, "source");
let after = render(&part, "extracted");
assert!(before.starts_with(b"P5\n200 200\n255\n"));
assert!(before.iter().filter(|&&v| v < 200).count() > 2500, "the source contains a drawn glyph");
assert_eq!(after, before, "extraction must keep every rendered pixel in fixture {i}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// A page listed twice whose widget's field keeps `/Kids` as an array object of its own: the
/// second widget joins that array, and the field keeps every widget it had.
#[test]
+3 -1
View File
@@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -92,7 +93,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
@@ -157,6 +158,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged |
| 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged |
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M7: single-glyph RTL ActualText ligatures join their word in Edit text. Regressions cover marks, two-byte codes, replacement and save/reopen; multi-glyph logical spans retain their order. Estimates unchanged. |