diff --git a/ROADMAP.md b/ROADMAP.md index 669c721f72..c7fc879448 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 40a962b466..92a5ae8c20 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -302,6 +302,72 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(), Ok(()) } +/// The page's content entries: one stream, or the elements of a contents array. +fn content_entries(doc: &Document, page: &Dict) -> Option> { + let c = page.get(b"Contents")?; + Some(match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + Object::Stream(_) => vec![c.clone()], + _ => return None, + }) +} + +fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> bool { + op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n)) +} + +/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that +/// contain them. Other content streams, and unknown entries on a rewritten stream, stay. +fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result { + let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; + let props = page_properties(doc, &page.dict); + let mut next = Vec::with_capacity(entries.len()); + let mut changed = false; + for entry in entries { + let Some(stream) = (match &*doc.resolve(&entry) { + Object::Stream(s) => Some(s.clone()), + _ => None, + }) else { + next.push(entry); + continue; + }; + let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops; + let mut ours = false; + for op in &mut ops { + if marked(doc, &props, op, ids) { + *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); + ours = true; + } + } + if !ours { + next.push(entry); + continue; + } + changed = true; + let mut dict = stream.dict; + dict.remove(b"Length"); + dict.remove(b"Filter"); + dict.remove(b"DecodeParms"); + let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops))); + let stored = match entry { + Object::Ref(r) => { + doc.set(r, stream); + Object::Ref(r) + } + _ => Object::Ref(doc.add(stream)), + }; + next.push(stored); + } + if changed { + let contents = match next.as_slice() { + [one] => one.clone(), + _ => Object::Array(next), + }; + doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?; + } + Ok(changed) +} + /// Decorative figure: its marked content on the page becomes an artifact, and the element /// leaves the structure tree (and the parent tree). pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> { @@ -309,23 +375,12 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> let ids: HashSet = mcids(&d).into_iter().collect(); let pages = pdfcraft_model::pages(doc); let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); - if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { - let props = page_properties(doc, &page.dict); - let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; - let mut changed = false; - for op in ops.iter_mut() { - if op.op == b"BDC" && bdc_mcid(doc, &props, op).is_some_and(|n| ids.contains(&n)) { - *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); - changed = true; - } - } - if changed { - let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops)))); - doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(s)))?; - // The parent tree no longer points those ids at the figure. - if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { - clear_parent_tree(doc, key, obj)?; - } + if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) + && artifact_marked_content(doc, page, &ids)? + { + // The parent tree no longer points those ids at the figure. + if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) { + clear_parent_tree(doc, key, obj)?; } } // Out of its parent's kids. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 60a0aa0f8f..167ae0078c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5049,6 +5049,80 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() { assert!(d.page_image_copy_file(0, 1).is_err()); } +/// #1324: marking a figure decorative rewrites that figure only. The header, added text and an +/// unknown entry on the original stream stay attached to the page. This lives in the engine tests +/// because edit and a11y are the same layer and cannot depend on each other. +#[test] +fn marking_a_figure_decorative_keeps_other_page_streams() { + let figure = "/Figure << /MCID 0 >> BDC 10 10 50 50 re f EMC"; + let header = "BT (HeaderSample) Tj ET"; + let added = "BT (AddedSample) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 7 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents [4 0 R 5 0 R 6 0 R] /StructParents 0 >>".into(), + format!("<< /VendorKey 7 /Length {} >>\nstream\n{figure}\nendstream", figure.len()), + format!("<< /PCMark /HeaderFooter /Length {} >>\nstream\n{header}\nendstream", header.len()), + format!( + "<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream", + added.len() + ), + "<< /Type /StructTreeRoot /K 8 0 R /ParentTree 10 0 R >>".into(), + "<< /Type /StructElem /S /Document /P 7 0 R /K [9 0 R] >>".into(), + "<< /Type /StructElem /S /Figure /P 8 0 R /Pg 3 0 R /K 0 >>".into(), + "<< /Nums [0 [9 0 R]] >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut doc = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(pdfcraft_edit::scan_markers(&doc).marks, vec![MarkKind::HeaderFooter]); + let before = pdfcraft_edit::scan_markers(&doc); + assert_eq!(before.added.len(), 1); + a11y::mark_decorative(&mut doc, pdfcraft_cos::ObjRef::new(9, 0)).unwrap(); + assert!(a11y::figures(&doc).is_empty()); + let page = &pdfcraft_model::pages(&doc)[0]; + let contents = page.dict.get(b"Contents").and_then(|c| c.as_array()).unwrap(); + assert_eq!(contents.len(), 3, "the other streams stay in the page's content list"); + let stream = |doc: &pdfcraft_cos::Document, n: u32| match &*doc.get(pdfcraft_cos::ObjRef::new(n, 0)) { + pdfcraft_cos::Object::Stream(s) => s.clone(), + other => panic!("object {n} is {other:?}"), + }; + let figure = stream(&doc, 4); + assert_eq!(figure.dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); + let drawn = String::from_utf8(figure.decoded().unwrap()).unwrap(); + assert!(drawn.contains("/Artifact BMC") && !drawn.contains("/MCID 0"), "{drawn}"); + let header = stream(&doc, 5); + assert_eq!(header.dict.name(b"PCMark"), Some(&b"HeaderFooter"[..])); + assert!(String::from_utf8(header.decoded().unwrap()).unwrap().contains("HeaderSample")); + assert!(stream(&doc, 6).dict.get(b"PCAdded").is_some()); + let after = pdfcraft_edit::scan_markers(&doc); + assert_eq!(after.marks, vec![MarkKind::HeaderFooter]); + assert_eq!(after.added.len(), 1); + assert_eq!(after.added[0].content, before.added[0].content); + assert_eq!(pdfcraft_edit::remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + let left = pdfcraft_model::pages(&doc)[0].dict.get(b"Contents").and_then(|c| c.as_array()).cloned().unwrap(); + let text: String = left + .iter() + .filter_map(|o| match &*doc.resolve(o) { + pdfcraft_cos::Object::Stream(s) => String::from_utf8(s.decoded().unwrap()).ok(), + _ => None, + }) + .collect(); + assert!(!text.contains("HeaderSample")); + assert_eq!(pdfcraft_edit::scan_markers(&doc).added[0].content, before.added[0].content); + assert_eq!(stream(&doc, 4).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); +} + /// Print as image (Acrobat's Advanced Print Setup): the print-ready PDF carries each page as a /// picture, the size of the page, with no text left to search; in grayscale the pictures are /// grey. A page used on several sheets is rendered once. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cdd75d125e..a65449ebf1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -484,14 +484,17 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(8.0); d.to = d.to.clamp(1, count); d.from = d.from.clamp(1, d.to); - let n = d.to - d.from + 1; ui.horizontal(|ui| { ui.label(tl!("Original: replace pages")); - ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + if from.changed() || to.changed() { + d.range_edited = true; + } ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); }); + let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; let max_start = d.src_pages.saturating_sub(n) + 1; d.src_from = d.src_from.clamp(1, max_start.max(1)); ui.horizontal(|ui| { @@ -1229,9 +1232,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { && let Some(d) = app.replace_draft.take() && app.still_pick_target(d.target) { - let n = d.to - d.from + 1; + let pages = if d.range_edited { (d.from - 1..d.to).collect() } else { d.pages.clone() }; + let n = pages.len(); app.apply_edit(Edit::ReplacePages { - pages: (d.from - 1..d.to).collect(), + pages, name: d.name.clone(), bytes: d.bytes.clone(), src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(), diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bbeb20727f..23754e13ef 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -82,6 +82,11 @@ pub struct ReplaceDraft { pub from: usize, pub to: usize, pub src_from: usize, + /// The selection when the dialog opened (0-based). OK replaces these pages until the + /// original range is edited, so a gap between selected pages is left alone. + pub pages: Vec, + /// The user changed the original from/to range, so OK uses that span. + pub range_edited: bool, /// The document whose pages are replaced, as it was when the dialog opened: OK replaces /// nothing if another document is active by then (a file opened meanwhile), or it changed. pub(crate) target: Option, @@ -516,10 +521,10 @@ impl PdfCraftApp { return; } }; - let targets = self.views[i].target_pages(); - let (from, to) = (targets.first().map_or(1, |p| p + 1), targets.last().map_or(1, |p| p + 1)); + let pages = self.views[i].target_pages(); + let (from, to) = (pages.first().map_or(1, |p| p + 1), pages.last().map_or(1, |p| p + 1)); let target = self.pick_target(self.active_ids().map(|(_, id)| id)); - self.replace_draft = Some(ReplaceDraft { name, bytes, src_pages, from, to, src_from: 1, target }); + self.replace_draft = Some(ReplaceDraft { name, bytes, src_pages, from, to, src_from: 1, pages, range_edited: false, target }); self.dialog = Some(crate::Dialog::ReplacePages); } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 537386e678..360cb4f619 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2228,6 +2228,75 @@ fn replace_pages_dialog_swaps_page_content() { assert_eq!(doc.can_undo(), Some("Replace page")); } +/// Pages 1 and 3 are selected. OK replaces those two and leaves the unchecked page between them. +#[test] +fn replace_split_selection_keeps_the_unchecked_page() { + fn sized(n: usize, width: u32, height: u32, label: &str) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 {width} {height}] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for i in 0..n { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i)); + let body = format!("BT /F1 24 Tf 20 150 Td ({label} {}) Tj ET", i + 1); + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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", objs.len() + 1).as_bytes()); + out + } + let open = |selected: &[usize], edited: bool| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("original.pdf", None, sized(3, 200, 300, "Original")).unwrap(); + app.views[0].select_pages(selected); + app.start_replace("other.pdf".into(), sized(5, 400, 500, "Replacement")); + if edited { + app.replace_draft.as_mut().unwrap().range_edited = true; + } + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| app); + h.run_steps(2); + h + }; + let sizes = |app: &PdfCraftApp| { + let doc = app.session.get(app.views[0].id).unwrap(); + doc.info.pages.iter().map(|p| (p.width.round() as i32, p.height.round() as i32)).collect::>() + }; + let mut split = open(&[0, 2], false); + let draft = split.state().replace_draft.as_ref().unwrap(); + assert_eq!((draft.from, draft.to, draft.pages.as_slice(), draft.range_edited), (1, 3, &[0usize, 2][..], false)); + egui_kittest::kittest::Queryable::get_by_label(&split, "OK").click(); + split.run_steps(3); + assert_eq!(texts_of(split.state(), 0), ["Replacement 1", "Original 2", "Replacement 2"]); + assert_eq!(sizes(split.state()), [(400, 500), (200, 300), (400, 500)]); + assert_eq!(split.state().session.get(split.state().views[0].id).unwrap().can_undo(), Some("Replace pages")); + split.state_mut().undo(); + split.run_steps(2); + assert_eq!(texts_of(split.state(), 0), ["Original 1", "Original 2", "Original 3"]); + assert_eq!(sizes(split.state()), [(200, 300), (200, 300), (200, 300)]); + + let mut span = open(&[0, 2], true); + egui_kittest::kittest::Queryable::get_by_label(&span, "OK").click(); + span.run_steps(3); + assert_eq!(texts_of(span.state(), 0), ["Replacement 1", "Replacement 2", "Replacement 3"]); + + let mut adjacent = open(&[1, 2], false); + egui_kittest::kittest::Queryable::get_by_label(&adjacent, "OK").click(); + adjacent.run_steps(3); + assert_eq!(texts_of(adjacent.state(), 0), ["Original 1", "Replacement 1", "Replacement 2"]); + assert_eq!(sizes(adjacent.state()), [(200, 300), (400, 500), (400, 500)]); +} + #[test] fn extract_options_and_rotate_pages_dialog() { let dir = temp_path("extract-sep"); diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 504e4b3678..d3e1f2c1ae 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -188,6 +188,30 @@ fn som_of(doc: &Document, f: &FieldDatum) -> String { doc.get(f.obj).as_dict().and_then(|d| text_of(doc, d, crate::pdf::SOM_KEY)).unwrap_or_else(|| f.name.clone()) } +/// The datasets text for a radio group. The AcroForm value is an appearance-state name +/// (`New York` is stored as `New_York`); `/PCOn` on the selected widget is the original item. +fn radio_dataset_value(doc: &Document, f: &FieldDatum, sel: &Option) -> String { + let Some(selected) = sel.as_deref().filter(|s| !s.is_empty() && *s != "Off") else { + return String::new(); + }; + let Some(group) = doc.get(f.obj).as_dict().cloned() else { return selected.to_string() }; + let kids = group.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_else(|| vec![Object::Ref(f.obj)]); + for kid in kids { + let Some(widget) = doc.resolve(&kid).as_dict().cloned() else { continue }; + let original = text_of(doc, &widget, b"PCOn"); + let state = widget + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| doc.resolve(&n).as_dict().cloned()) + .and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off")); + if state.as_deref() == Some(selected) || original.as_deref() == Some(selected) { + return original.unwrap_or_else(|| selected.to_string()); + } + } + selected.to_string() +} + /// The on and off item values of a check box (`/PCItems`), or the widget's on state. fn items_of(doc: &Document, f: &FieldDatum) -> (String, String) { let d = doc.get(f.obj); @@ -439,7 +463,7 @@ fn wanted(doc: &Document, fields: &[FieldDatum], tpl: Option<&Template>) -> Vec< let (on_item, off_item) = items_of(doc, f); if *on { on_item } else { off_item } } - FieldData::Radio(sel) => sel.clone().unwrap_or_default(), + FieldData::Radio(sel) => radio_dataset_value(doc, f, sel), FieldData::None => continue, }; out.push(Want { som, path, text, ensure: false }); diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 1e1579005a..cca62e64e7 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -421,6 +421,9 @@ impl Emitter<'_> { } }; d.set(b"Parent".to_vec(), Object::Ref(parent)); + // Appearance states are PDF names, so a space becomes an underscore. The datasets + // still hold the template's text (`/PCOn`); writing the name back would drop the selection. + d.set(b"PCOn".to_vec(), Object::String(PdfString::text(on))); let state = state_name(on); let chosen = w.value.is_some(); d.set(b"AS".to_vec(), Object::name(if chosen { &state } else { "Off" })); diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index da68027d75..1e8730586d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -426,6 +426,61 @@ fn the_datasets_packet_is_written_from_the_fields_and_read_back() { assert!(!write_datasets(&mut plain, &[]).unwrap().written); } +/// A radio item whose text contains a space keeps that text in the datasets. The appearance +/// state is a PDF name (`New_York`); writing it back used to clear the selection on the next layout. +#[test] +fn spaced_radio_values_stay_in_the_datasets() { + for (value, state) in [("New York", "New_York"), ("Y", "Y")] { + let xdp = format!( + r#" + +
{value}before{value}
+
"# + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert!(report.warnings.is_empty(), "{value:?} {:?}", report.warnings); + let fields = field_data(&doc); + assert!(fields.iter().any(|f| f.name == "city" && f.data == FieldData::Radio(Some(state.into()))), "{value:?} {fields:?}"); + let unchanged = write_datasets(&mut doc, &fields).unwrap(); + assert!(!unchanged.written && unchanged.warnings.is_empty(), "{value:?} {unchanged:?}"); + let mut fields = fields; + for f in &mut fields { + if f.name == "note" { + f.data = FieldData::Text("after".into()); + } + } + let write = write_datasets(&mut doc, &fields).unwrap(); + assert!(write.written && write.warnings.is_empty(), "{value:?} {write:?}"); + let packets = read_packets(&doc).unwrap().unwrap(); + let data = parse_datasets(&packets.xdp).unwrap(); + assert_eq!(data.text_at(&som_to_path("form[0].city[0]")), Some(value)); + assert_eq!(data.text_at(&som_to_path("form[0].agree[0]")), Some(value)); + assert_eq!(data.text_at(&som_to_path("form[0].note[0]")), Some("after")); + let selected: Vec<(String, Option)> = layout_xml(&packets.xdp) + .unwrap() + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|item| match item { + Item::Widget(w) if w.name == "first" || w.name == "second" => Some((w.name.clone(), w.value.clone())), + _ => None, + }) + .collect(); + assert_eq!(selected, vec![("first".into(), Some(value.into())), ("second".into(), None)], "{value:?}"); + } +} + #[test] fn static_forms_read_their_values_from_the_datasets() { let doc = Document::open(Arc::new(static_shell("Ada1"))).unwrap(); diff --git a/docs/gaps.md b/docs/gaps.md index 38b1249589..9163b1bf47 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -130,15 +130,15 @@ update the numbers. - **Missing:** fixes for the ≈ 13 of ≈ 25 filed reports of features marked `shipped` that give wrong results still open, plus triage of ≈ 220 other open issues. - **Evidence:** 245 open issues on 2026-10-10. The first batch of `fix(...)` reports was fixed by - 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). Stamps placed - on a rotated page read upright: rubber stamps, natural-size and explicit-rectangle images, and - PDF-page stamps (#1398). The rest of the backlog is untriaged. 2026-10-10 (#799, #800, #807, #809, #810, #811, #815, #816, #817, #818, #819, #821). A later batch keeps a cloudy comment flatten cannot draw (#1317), leaves a refused text-box restyle or resize unchanged (#1327), keeps arrow defaults off plain lines (#1302), stops a custom text match on the separator instead of the next glyph (#1315), and states the vertical-text redaction refusal without claiming an overlap (#1340). Organize Pages opens on the page being - read, selected and scrolled into view (#1910). The rest of the backlog is untriaged. + read, selected and scrolled into view (#1910). Stamps placed on a rotated page read upright + (#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the + selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place + when a figure is marked decorative (#1324). The rest of the backlog is untriaged. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. diff --git a/docs/roadmap.md b/docs/roadmap.md index bce6f8e1cc..a51e6c9021 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -128,7 +128,7 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | @@ -194,6 +194,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M7 #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps; same-baseline copies are still replaced. Estimates unchanged. | | 2026-10-12 | minor | M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection. Estimates unchanged. | | 2026-10-12 | minor | M12: bounded text-only auto-tag preview through the engine and automation; actual PDF structure writing and visual review remain open. Estimates unchanged. | +| 2026-10-12 | minor | Gap 6: XFA radio values with spaces stay in the datasets (#1319); Replace Pages keeps a gap in a split selection (#1305); decorative figures rewrite only their own content stream (#1324). Estimates unchanged. | | 2026-10-12 | minor | M7: keep added Arabic copy/search in typing order with per-line ActualText; preserve that order in the editor and keep visual runs separate. Font-independent regressions and tool save/reopen coverage; font fallback and bidi typing remain partial. Estimates unchanged. | | 2026-10-12 | minor | M3: case-insensitive Find matches normal, final and capital Greek sigma; exact search, word boundaries, glyph ranges and match limits have regression coverage. The tab-menu test uses Finder on macOS. Estimates unchanged. | | 2026-10-12 | minor | M14: Windows low-DPI JetBrains Mono hinting candidate; raster and layout evidence recorded without a Windows runtime claim. Estimates unchanged. |