mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
* Keep selected form values and page content through later edits (#1319, #1305, #1324, #1306) An XFA radio item whose text contains a space was written back as an appearance-state name, so the next layout selected nothing. Replace Pages filled the gap between a split selection. Marking a figure decorative replaced every content stream on the page. Control clicks and drags accepted coordinates that overflow to infinity. * Move the decorative-figure regression out of pdfcraft-edit edit and a11y are both layer 4, so a dev-dependency from edit to a11y fails the layer check. The same regression now runs in the engine, which already depends on both. * Drop pdfcraft-a11y from the edit crate's lockfile entry * Run a tool after a background open of the file it asked for A tool chosen with no document opened the picked file on the worker, but only ran when that open finished inside the picker callback. Remember the command on the pending open, and let the command test wait for the worker. * Wait for background opens in the command tests Opening a picked file or a recent file finishes on the worker, so a fixed handful of frames can assert before the tab exists. * Compare batch copies to the PDF that was written A new text PDF stamps the current second, so a later pdf() call is a different file. The numbered copy and the source must match the bytes that were saved. --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
9b6dab142f
commit
02a4d81a22
@@ -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.
|
||||
|
||||
|
||||
+72
-17
@@ -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<Vec<Object>> {
|
||||
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<i64>) -> 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<i64>) -> Result<bool, AltError> {
|
||||
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<i64> = 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<usize>,
|
||||
/// 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<PickTarget>,
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (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::<Vec<_>>()
|
||||
};
|
||||
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");
|
||||
|
||||
+25
-1
@@ -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>) -> 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 });
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><contentArea x="36pt" y="36pt" w="540pt" h="720pt"/><medium short="612pt" long="792pt"/></pageArea></pageSet>
|
||||
<exclGroup name="city" layout="lr-tb">
|
||||
<field name="first" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>{value}</text></items></field>
|
||||
<field name="second" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>Boston</text></items></field>
|
||||
</exclGroup>
|
||||
<field name="note" w="108pt" h="18pt"><ui><textEdit/></ui></field>
|
||||
<field name="agree" w="108pt" h="18pt"><ui><checkButton/></ui><items><text>{value}</text><text>No</text></items></field>
|
||||
</subform>
|
||||
</template>
|
||||
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/"><xfa:data><form><city>{value}</city><note>before</note><agree>{value}</agree></form></xfa:data></xfa:datasets>
|
||||
</xdp:xdp>"#
|
||||
);
|
||||
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<String>)> = 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("<form1><page1><name>Ada</name><agree>1</agree></page1></form1>"))).unwrap();
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
+2
-1
@@ -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.<br>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).<br>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.<br>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.<br>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<br>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.<br>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<br>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. |
|
||||
|
||||
Reference in New Issue
Block a user