From fc14719dff434e692123cb19b8c94e07a2d314cc Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:18:41 +0200 Subject: [PATCH 01/82] feat(forms): edit checkbox and radio export values (#231) --- ROADMAP.md | 2 ++ crates/automation/README.md | 12 ++++++++ crates/automation/src/forms.rs | 17 +++++++++- crates/automation/src/tools.rs | 4 ++- crates/automation/tests/form_bulk.rs | 23 ++++++++++++++ crates/forms/README.md | 13 ++++++++ crates/forms/src/author.rs | 46 ++++++++++++++++++++++++++++ crates/forms/src/lib.rs | 9 ++++++ crates/forms/src/tests.rs | 39 +++++++++++++++++++++++ crates/ui-egui/src/prepare.rs | 8 ++++- crates/ui-egui/tests/prepare.rs | 33 ++++++++++++++++++++ crates/xfdf/src/lib.rs | 13 +++++--- crates/xfdf/src/tests.rs | 33 ++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 14 files changed, 246 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a035c9bf35..3013de8b1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6 #231: edit checkbox/radio export values through individual field properties and automation, preserving selection and appearance; headless regression coverage, desktop visual verification pending; estimates unchanged) + **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 (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) diff --git a/crates/automation/README.md b/crates/automation/README.md index f2e4ec9282..f20ad35ea1 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -184,3 +184,15 @@ unapplied with a warning. `doc_batch` runs conversion, OCR and compression over files and optionally recursive folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. + +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 7c60425a8c..6089d5a36a 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -232,6 +232,9 @@ impl Automation { if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } + if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) { + o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>())); + } match f.kind { FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); @@ -327,6 +330,9 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { + if a.get("widget").is_some() && a.get("export_value").is_none() { + return Err(bad("widget requires export_value")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +350,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "export_value", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -396,6 +402,15 @@ impl Automation { }; Ok(FieldProps { rect, + export_value: a + .opt_str("export_value")? + .map(|value| -> Result<_> { + let index = a.opt_int("widget")?.unwrap_or(1); + let index = + usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((index, value.to_owned())) + }) + .transpose()?, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), read_only: a.opt_bool("read_only")?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..be85f00077 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -364,7 +364,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and export_value require a single field. export_value edits a checkbox or radio widget (widget: 1-based, default 1), preserving its selected/default state; widgets sharing that state keep the same export value. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with({ let mut input = schema( @@ -373,6 +373,8 @@ pub fn tools() -> Vec { "field": { "type": "string", "description": "The field's current name." }, "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, + "export_value": { "type": "string", "minLength": 1, "description": "Checkbox/radio export value; not Off; at most 4096 UTF-8 bytes." }, + "widget": { "type": "integer", "minimum": 1, "description": "1-based widget for export_value (default 1)." }, "tooltip": { "type": "string" }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/form_bulk.rs index 8b8904c974..4296cbc891 100644 --- a/crates/automation/tests/form_bulk.rs +++ b/crates/automation/tests/form_bulk.rs @@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() { assert_eq!(bytes(&a, id), before); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn button_export_edit_is_one_undo_step_and_survives_save() { + let (mut a, id, dir) = setup("button-export"); + tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]})); + tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}})); + let before = bytes(&a, id); + call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1})); + let f = tool(&mut a, "form_fields", json!({"doc": id})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(bytes(&a, id), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + let f = tool(&mut a, "form_fields", json!({"doc": reopened})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc2431..bc8e19fc95 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -57,6 +57,19 @@ delete_field(&mut doc, "email")?; buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +## Button export values + +`FieldProps::export_value = Some((widget_index, value))` edits a checkbox or radio +button's export value. The API index is zero-based. It writes a complete `/Opt` +mapping, preserving appearance names, selected states and reset defaults; an +export-only change leaves custom appearance streams intact. Widgets sharing an +appearance state keep the same export value. Conflicting shared-state mappings, +ambiguous choices, invalid widget indices, empty values, `Off`, and values over +4096 UTF-8 bytes are refused. + +`Field::export_values` resolves button values for data exchange. An unchecked +button still exports as `Off` in the data formats. + ## Not yet Rich-text values and exact appearance metrics/layout remain incomplete. JavaScript diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 6d49be1001..a8c99cb483 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -68,6 +68,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in @@ -845,6 +848,36 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) } +/// Build a complete /Opt mapping before modifying the document. Appearance names, +/// /V and /DV stay untouched, so an export-value edit does not change selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -858,6 +891,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -879,6 +916,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0256bfafb9..e3fa748651 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1943,3 +1943,42 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() { assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]); assert!(filled.rules.is_empty()); } + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 37747f14c7..762d090254 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -758,6 +758,7 @@ pub struct FieldDraft { pub default: String, /// Check boxes and radio buttons: this widget's "on" state (export value). pub on_state: String, + pub export_value: String, /// 0 = auto. pub font_size: f64, /// Left, bottom, width, height in points. @@ -824,6 +825,7 @@ impl FieldDraft { quadding: f.quadding, default: f.default.first().cloned().unwrap_or_default(), on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(), + export_value: f.export_of(widget).unwrap_or_default().to_string(), font_size: da_size(&f.da), position: [r[0], r[1], r[2] - r[0], r[3] - r[1]], rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0), @@ -877,6 +879,7 @@ impl FieldDraft { multiline: ch(self.multiline, o.multiline), max_len: ((self.limit, self.max_len) != (o.limit, o.max_len)).then_some((self.limit && self.max_len > 0).then_some(self.max_len)), options: (self.options != o.options).then(|| self.options.clone()), + export_value: (self.export_value != o.export_value).then(|| (self.widget, self.export_value.clone())), font_size: ((self.font_size - o.font_size).abs() > 1e-6).then_some(self.font_size), rect: (self.position != o.position).then(|| { let [x, y, w, h] = self.position; @@ -1123,7 +1126,10 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke FormFieldKind::CheckBox | FormFieldKind::Radio => { use pdfcraft_engine::field_flags as ff; let on = d.on_state.clone(); - ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", &on)])); + ui.horizontal(|ui| { + let label = ui.label(crate::i18n::fmt(tl!("Export Value: {on}"), &[("on", "")])); + ui.text_edit_singleline(&mut d.export_value).labelled_by(label.id); + }); let mut checked = !d.default.is_empty() && d.default == on; let label = if d.kind == FormFieldKind::CheckBox { tl!("Check box is checked by default") } else { tl!("Button is checked by default") }; diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index b23f1a9f8f..81359d8bc2 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -497,3 +497,36 @@ fn actions_tab_adds_and_removes_actions() { ] ); } + +#[test] +fn button_export_properties_apply_cancel_and_undo() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + h.get_by_label("Options").click(); + h.run_steps(2); + let original = h.state().field_props.as_ref().unwrap().export_value.clone(); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Cancel").click(); + h.run_steps(2); + h.state_mut().open_field_props("agree", 0); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); + h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some("Accepted")); + } + h.state_mut().execute("edit.undo"); + h.run_steps(3); + { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap(); + assert_eq!(f.export_of(0), Some(original.as_str())); + } +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index d9287101ba..9b1b439caf 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -420,7 +420,7 @@ fn xfdf_fields(doc: &Document, out: &mut String) { } node.values = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => vec!["Off".into()], - _ => f.value.clone(), + _ => f.export_values(), }; } fn write_node(name: &str, node: &Node, out: &mut String) { @@ -480,7 +480,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V body.extend_from_slice(b" /V "); match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let v = f.value.first().cloned().unwrap_or_else(|| "Off".into()); + let v = f.export_values().first().cloned().unwrap_or_else(|| "Off".into()); pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body); } FieldKind::List if f.value.len() > 1 => { @@ -575,7 +575,7 @@ pub fn export_data(doc: &Document, format: Format) -> String { .map(|f| { let v = match f.kind { FieldKind::CheckBox | FieldKind::Radio if f.value.is_empty() => "Off".to_string(), - _ => f.value.join(", "), + _ => f.export_values().join(", "), }; (f.name, v) }) @@ -984,8 +984,11 @@ pub fn data_values(bytes: &[u8]) -> Result)>, DataError .into_iter() .filter(|f| !matches!(f.kind, FieldKind::PushButton | FieldKind::Signature)) .map(|f| { - let v = - if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { vec!["Off".to_string()] } else { f.value }; + let v = if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.value.is_empty() { + vec!["Off".to_string()] + } else { + f.export_values() + }; (f.name, v) }) .collect()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index be747d77e1..d61273f1b5 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -887,3 +887,36 @@ fn duplicate_xfdf_annotation_flags_do_not_turn_on_unrelated_bits() { assert_eq!(square.get(b"F").and_then(Object::as_int), Some(expected), "flags={flags}"); } } + +#[test] +fn edited_button_export_values_round_trip_in_all_data_formats() { + use pdfcraft_forms::{FieldProps, set_props}; + let mut doc = blank(); + add_field(&mut doc, 0, [80.0, 650.0, 94.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "S".into() }, None).unwrap(); + add_field(&mut doc, 0, [100.0, 650.0, 114.0, 664.0], &NewField::Radio { group: Some("Size".into()), export: "L".into() }, None).unwrap(); + set_props(&mut doc, "Agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + set_props(&mut doc, "Size", &FieldProps { export_value: Some((0, "Small".into())), ..Default::default() }).unwrap(); + set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); + set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap(); + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "")]; + exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes())); + for (index, bytes) in exports.into_iter().enumerate() { + if index < 2 { + let values = data_values(&bytes).unwrap(); + assert_eq!(values.iter().find(|(n, _)| n == "Agree").unwrap().1, vec!["Accepted"]); + assert_eq!(values.iter().find(|(n, _)| n == "Size").unwrap().1, vec!["Small"]); + } else { + // data_values reads XFDF/FDF/PDF; import also handles XML/CSV/TXT. + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("Accepted") && text.contains("Small"), "{text}"); + } + let mut target = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + set_value(&mut target, "Agree", &FieldValue::Check(false)).unwrap(); + set_value(&mut target, "Size", &FieldValue::Radio(Some("L".into()))).unwrap(); + import(&mut target, &bytes).unwrap(); + let fs = fields(&target); + assert_eq!(fs.iter().find(|f| f.name == "Agree").unwrap().export_values(), vec!["Accepted"]); + assert_eq!(fs.iter().find(|f| f.name == "Size").unwrap().export_values(), vec!["Small"]); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8f4194b932..f5189bc33e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4896,8 +4896,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically"] -notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" +evidence = ["crates/forms/src/tests.rs::properties_rename_and_delete", "crates/automation/tests/automation.rs::preparing_a_form_through_tools", "crates/forms/src/tests.rs::options_tab_flags_alignment_and_defaults", "crates/ui-egui/tests/prepare.rs::options_tab_sets_flags_alignment_and_defaults", "crates/automation/tests/form_bulk.rs::bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically", "crates/forms/src/tests.rs::editing_button_export_values_preserves_selection_and_appearance", "crates/forms/src/tests.rs::invalid_button_export_edits_leave_document_unchanged", "crates/xfdf/src/tests.rs::edited_button_export_values_round_trip_in_all_data_formats", "crates/automation/tests/form_bulk.rs::button_export_edit_is_one_undo_step_and_survives_save", "crates/ui-egui/tests/prepare.rs::button_export_properties_apply_cancel_and_undo"] +notes = "text: alignment, default, multi-line, scroll, rich text flag, limit, password, file selection, spell check, comb; choices: items, default, sort, custom text, multiple selection, commit immediately; check boxes and radios: checked by default, radios in unison, individual widget export value via /Opt without changing selection or appearance; XFDF/FDF/XML/CSV/TXT exports use the mapped values. Export editing has headless Apply/Cancel/Undo and save/reopen coverage; desktop typing/appearance has not been visually verified. Shared GUI offers applicable text/choice flags, alignment, text limits and check styles; choices/defaults remain individual GUI edits" [[feature]] id = "form.props-actions" From 0e8d4d70fabbdc4f030af86f801604d1db5766a6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:31:14 +0200 Subject: [PATCH 02/82] test(forms): type export values through the properties widget --- crates/ui-egui/tests/prepare.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 81359d8bc2..896eeff196 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -500,6 +500,14 @@ fn actions_tab_adds_and_removes_actions() { #[test] fn button_export_properties_apply_cancel_and_undo() { + fn retype(h: &mut Harness<'static, PdfCraftApp>, value: &str) { + use egui::accesskit::Role; + h.get_by_role_and_label(Role::TextInput, "Export Value: ").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role_and_label(Role::TextInput, "Export Value: ").type_text(value); + h.run_steps(2); + } let mut h = harness(); h.state_mut().execute("form.prepare"); h.run_steps(2); @@ -508,13 +516,15 @@ fn button_export_properties_apply_cancel_and_undo() { h.get_by_label("Options").click(); h.run_steps(2); let original = h.state().field_props.as_ref().unwrap().export_value.clone(); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + retype(&mut h, "Accepted"); h.get_by_label("Cancel").click(); h.run_steps(2); h.state_mut().open_field_props("agree", 0); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().export_value, original); - h.state_mut().field_props.as_mut().unwrap().export_value = "Accepted".into(); + h.get_by_label("Options").click(); + h.run_steps(2); + retype(&mut h, "Accepted"); h.get_by_label("OK").click(); h.run_steps(3); { From e2be8db7fbde73c0d10ba896b5dc19561f659bb8 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:33:19 +0200 Subject: [PATCH 03/82] test(ui): stabilize Reduce metadata and isolate output directories --- crates/ui-egui/tests/create.rs | 35 ++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/tests/create.rs b/crates/ui-egui/tests/create.rs index bfbba2e131..4537ab1fa6 100644 --- a/crates/ui-egui/tests/create.rs +++ b/crates/ui-egui/tests/create.rs @@ -69,12 +69,37 @@ fn opening_images_and_text_converts_them_to_new_pdfs() { assert!(app.open_bytes("junk.png", None, b"\x89PNG\r\n\x1a\nnot really".to_vec()).is_err()); } +// Use a directory claimed exclusively by this run, including after process-ID reuse. +struct ReduceDir(std::path::PathBuf); +impl ReduceDir { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + for _ in 0..128 { + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pdfcraft-reduce-{}-{n}", std::process::id())); + match std::fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("creating Reduce test directory: {error}"), + } + } + panic!("no unused Reduce test directory after 128 attempts"); + } +} +impl Drop for ReduceDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + /// Open `name` and run Reduce File Size on it, saving to `out` without a dialog. fn reduce(name: &'static str, bytes: Vec, out: &std::path::Path) -> egui_kittest::Harness<'static, PdfCraftApp> { let out2 = out.to_path_buf(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + // Keep creation and optimization metadata identical across wall-clock seconds. + app.session = std::mem::take(&mut app.session).with_clock(|| 1_700_000_000); app.open_bytes(name, None, bytes).unwrap(); app.save_override = Some(out2.to_string_lossy().into_owned()); app @@ -105,9 +130,8 @@ fn reduce_file_size_writes_a_compact_copy() { enc.set_depth(png::BitDepth::Eight); enc.write_header().unwrap().write_image_data(&px).unwrap(); } - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); let h = reduce("photo.png", photo, &out); let bytes = std::fs::read(&out).unwrap(); assert!(bytes.starts_with(b"%PDF-")); @@ -119,9 +143,8 @@ fn reduce_file_size_writes_a_compact_copy() { #[test] fn reduce_file_size_saves_nothing_when_the_file_is_already_small() { use egui_kittest::kittest::Queryable; - let dir = std::env::temp_dir().join(format!("pdfcraft-reduce-small-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let out = dir.join("reduced.pdf"); + let dir = ReduceDir::new(); + let out = dir.0.join("reduced.pdf"); // A new text PDF is already compact: a copy would be no smaller (#490). let h = reduce("notes.txt", "lorem ipsum ".repeat(500).into_bytes(), &out); assert!(!out.exists(), "no copy was written"); From 54e2895ae6a3b3a9572c1c4e4778232bba80a14e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:38:46 +1100 Subject: [PATCH 04/82] fix(ui): the added-text editor keeps the box's alignment Opening a centred or right-aligned Add text box drew its text at the left edge, a layout the box never had, and Ctrl+Z inside the box (which goes to the text, not the document) left it showing that way. The editor now lays the text out with the box's alignment. Justified text is still shown left-aligned while editing (TextEdit can't justify). --- crates/ui-egui/src/content_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/content.rs | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 674210529e..f8db4c8ca5 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -560,6 +560,13 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + // Laid out as the page draws it, so opening the box doesn't show it realigned. + // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter. + let halign = match t.style.align { + pdfcraft_engine::TextAlign::Center => egui::Align::Center, + pdfcraft_engine::TextAlign::Right => egui::Align::Max, + pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, + }; ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let resp = ui.add( @@ -567,6 +574,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .font(font) .desired_width(r.width().max(60.0)) .desired_rows(1) + .horizontal_align(halign) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 515e3e04f1..de06d44b15 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -715,6 +715,28 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { } /// Where a mode bar tab is, by its label. +/// Opening right-aligned added text to edit it shows it right-aligned, as the page does, not at +/// the box's left edge, where it never was. +#[test] +fn the_text_editor_keeps_the_boxs_alignment() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 200.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + let pixels = h.render().unwrap(); + let left = ink(&pixels, egui::Rect::from_min_max(r.min, pos2(r.center().x, r.max.y))); + let right = ink(&pixels, egui::Rect::from_min_max(pos2(r.center().x, r.min.y), r.max)); + assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); +} + fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } From dcf33ed1c85e8804d76d21d04222bb4424f27878 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:38:28 +1100 Subject: [PATCH 05/82] fix(ui): Ctrl+Z in an open paragraph undoes the document, not stale text Ctrl+Z is left to a focused text field, so in the paragraph editor it went to the box's own Undo history. That history still held the paragraph's text from before the last formatting change, so Ctrl+Z put it back into a box laid out for the page after the change, a layout the page never had, and the document kept the change. The box shared its id across paragraphs too, so it could put back another paragraph's text. The history of the paragraph editor and the added-text box now starts at what the page has. Undo and Redo keep the keys only while the box holds typing the page doesn't have; then they reach the document. An open box is applied (as clicking away applies it) and closed before an Undo or Redo step, so it never shows a state the document has left; text it can't apply keeps it open and stops the step. --- crates/ui-egui/src/commands.rs | 5 ++- crates/ui-egui/src/content_ui.rs | 23 +++++++++++-- crates/ui-egui/src/edit_text_ui.rs | 29 +++++++++++++++- crates/ui-egui/src/editing.rs | 54 +++++++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 55 ++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 92bc0b360e..ee19c168eb 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -726,7 +726,10 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } - if typing && !spec.in_text { + // A box typed into on the page (a paragraph being edited, added text) keeps Undo and + // Redo only while it holds typing the page doesn't have; then they reach the document. + let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied(); + if typing && !spec.in_text && !page_box { continue; } let Some(key) = Key::from_name(s.key) else { continue }; diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index f8db4c8ca5..c99c2cd787 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -66,8 +66,18 @@ pub struct TextDraft { /// Display space. pub rect: [f64; 4], pub text: String, + /// The box's formatting; its `text` is what the page has (empty for new text). pub style: AddedText, pub focus: bool, + /// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]). + pub typing_from: Option, +} + +impl TextDraft { + /// Typing the page doesn't have yet: Undo takes it back in the box before touching the document. + pub(crate) fn has_unapplied_typing(&self) -> bool { + self.text != self.style.text + } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -394,7 +404,7 @@ pub(crate) fn page_input( let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]]; style.rect = rect; - cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true }); + cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None }); consumed = true; if typed.is_some() { view.pending_edit = typed; @@ -432,7 +442,7 @@ pub(crate) fn page_input( if let Some(edit) = cv.finish_draft(added) { view.pending_edit = Some(edit); } - cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true }); + cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None }); cv.selected = Some((page, i)); consumed = true; return consumed; @@ -567,6 +577,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad pdfcraft_engine::TextAlign::Right => egui::Align::Max, pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, }; + let input = input_id(); + crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text); ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let resp = ui.add( @@ -578,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), + .id(input), ); if t.focus { resp.request_focus(); @@ -641,6 +653,11 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { } } +/// The added-text box's text field. +pub(crate) fn input_id() -> egui::Id { + egui::Id::new("added-text-input") +} + /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 3a1ab04367..f2c5c55151 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -256,6 +256,29 @@ pub struct LineEditor { /// horizontal scale (%), and what they were. pub extras: Extras, extras0: Extras, + /// The text the box's own Undo history starts from; see [`forget_typing_before`]. + typing_from: Option, +} + +/// The paragraph editor's text box. +pub(crate) fn input_id() -> egui::Id { + egui::Id::new("edit-text-line-input") +} + +/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown +/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it), +/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches +/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was +/// before a format change, into a box showing the page as it is now. +pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option, text: &str) { + if from.as_deref() == Some(text) { + return; + } + if let Some(mut state) = egui::TextEdit::load_state(ctx, id) { + state.clear_undoer(); + egui::TextEdit::store_state(ctx, id, state); + } + *from = Some(text.to_owned()); } #[derive(Clone, Copy, Debug, PartialEq)] @@ -1199,6 +1222,7 @@ fn open_editor( family_chosen: false, extras: Extras::default(), extras0: Extras::default(), + typing_from: None, }); } @@ -1668,8 +1692,10 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; + let input = input_id(); + forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original); let output = egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) + .id(input) .font(font.clone()) .margin(egui::Margin::ZERO) .layouter(&mut layouter) @@ -1801,6 +1827,7 @@ mod tests { family_chosen: false, extras: Extras::default(), extras0: Extras::default(), + typing_from: None, }; (app, ed) } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2fdc2e9d02..6f16837242 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -229,6 +229,9 @@ impl PdfCraftApp { fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; + if !self.close_text_editors(i, id) { + return; + } let result = if undo { self.session.undo(id) } else { self.session.redo(id) }; match result { Ok(label) => { @@ -247,6 +250,57 @@ impl PdfCraftApp { } } + /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as + /// it is before the step, so it is applied as clicking away applies it, then closed. Left + /// open, it kept showing what the step took away, and wrote from it when it was next applied. + /// What was typed is then the step Undo takes back. False (nothing is undone, and the box + /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be + /// applied: it is shown, never lost. + pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(view) = self.views.get_mut(i) else { return true }; + if let Some(c) = view.content.blocked() { + view.content.hold_blocked(); + self.notify(crate::content_ui::undrawable_message(c)); + return false; + } + if let Some(ed) = view.line_editor.take() + && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone()) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.line_editor = Some(ed); + } + return false; + } + let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); + let Some(view) = self.views.get_mut(i) else { return true }; + let draft = view.content.draft.clone(); + if let Some(edit) = view.content.finish_draft(&added) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.content.draft = draft; + } + return false; + } + true + } + + /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added + /// text. + pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + } + + /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't + /// have yet: Undo and Redo keys take it back in the box first. + pub(crate) fn page_typing_unapplied(&self) -> bool { + self.active.and_then(|i| self.views.get(i)).is_some_and(|v| { + v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing()) + }) + } + /// Apply any edit a view queued this frame (organize toolbar, keys). /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 73464d33e7..2fcf67dc42 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6019,3 +6019,58 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); } + +/// The paragraph opened for typing, as a person opens it: a click on its text. +fn open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + click_at(h, at); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); +} + +/// Ctrl+Z after a formatting change, with the paragraph still open: the change is undone and +/// the editor closes. The box's own Undo used to take the key, putting back the text it had +/// before the change into a box laid out for the page after it, and the document kept the change. +#[test] +fn undo_with_the_paragraph_open_undoes_the_formatting() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Edit text")); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold"); + assert!(s.views[0].line_editor.is_some(), "still editing"); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), None, "the formatting is undone"); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica"); + assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed"); +} + +/// Undo while typing takes back the typing in the box first; the document is left alone. +#[test] +fn undo_while_typing_takes_back_the_typing_first() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, original); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("still typing"); + assert_eq!(ed.text, original, "the typing is taken back"); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched"); +} From 5c1c3462eb429deab889b9552b2b35821d950004 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:40:02 +1100 Subject: [PATCH 06/82] M7: align the selected lines of a paragraph or text box on their own Format text's alignment always re-aligned the whole paragraph or added text box, and clicking it closed an added text box, losing the selection, so one line couldn't be aligned. With text selected, alignment now applies to the lines (text between line breaks) the selection touches; with only the caret, to the whole box as before. - Added text stores each line's alignment in its /PCAdded parameters (/Aligns, null where a line follows /Align). A justified line's last row is not stretched. - An edited paragraph sends its rows' alignments with each rewrite while it is open (BlockStyle::aligns), in both the single-style and the styled-region rewrite. - Both editors draw each line by its own alignment, and the alignment buttons show the selected line's. An added text box stays open while Format text is used, its style applied at once; clicks are judged by where they were pressed, so a touch or pen tap on the panel doesn't close either editor. - Automation: `aligns` on page_add_text, content_update and text_edit. --- crates/automation/src/content.rs | 51 +++++++-- crates/automation/src/lib.rs | 11 +- crates/automation/src/tools.rs | 3 + crates/automation/tests/it/automation.rs | 23 ++++ crates/edit/src/added.rs | 136 +++++++++++++++-------- crates/edit/src/added/arabic_tests.rs | 4 +- crates/edit/src/lib.rs | 1 + crates/edit/src/paragraph_align.rs | 135 ++++++++++++++++++++++ crates/edit/src/tests.rs | 71 ++++++++++++ crates/edit/src/text.rs | 44 +++++--- crates/edit/src/text_regions.rs | 15 ++- crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 2 + crates/ui-egui/src/content_ui.rs | 117 +++++++++++++++---- crates/ui-egui/src/edit_text_ui.rs | 39 ++++++- crates/ui-egui/src/format_text_ui.rs | 60 +++++++++- crates/ui-egui/src/panels.rs | 62 +++++++++-- crates/ui-egui/tests/it/content.rs | 32 ++++++ crates/ui-egui/tests/it/editing.rs | 53 +++++++++ parity/acrobat-features.toml | 4 +- 20 files changed, 738 insertions(+), 126 deletions(-) create mode 100644 crates/edit/src/paragraph_align.rs diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index 8926a1368b..c162e739df 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -53,17 +53,48 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { t.color = parse_color(c)?; } if let Some(al) = a.opt_str("align")? { - t.align = match al { - "left" => TextAlign::Left, - "center" | "centre" => TextAlign::Center, - "right" => TextAlign::Right, - "justify" => TextAlign::Justify, - other => return Err(bad(format!("unknown align {other:?}"))), - }; + t.align = parse_align(al)?; + // The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one. + t.aligns.clear(); + } + if let Some(aligns) = opt_aligns(a)? { + let count = pdfcraft_engine::paragraph_align::count(&t.text); + t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align); } Ok(()) } +pub(crate) fn parse_align(al: &str) -> Result { + Ok(match al { + "left" => TextAlign::Left, + "center" | "centre" => TextAlign::Center, + "right" => TextAlign::Right, + "justify" => TextAlign::Justify, + other => return Err(bad(format!("unknown align {other:?}"))), + }) +} + +/// Most entries `aligns` takes: one per paragraph, and no text has this many. +const MAX_ALIGNS: usize = 4096; + +/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +pub(crate) fn opt_aligns(a: &Args) -> Result>>> { + let Some(v) = a.get("aligns") else { return Ok(None) }; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + if items.len() > MAX_ALIGNS { + return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); + } + items + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) => parse_align(s).map(Some), + _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + }) + .collect::>>() + .map(Some) +} + impl Automation { pub(crate) fn content_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; @@ -153,6 +184,8 @@ impl Automation { match &mut content { AddedContent::Text(t) => { if let Some(s) = a.opt_str("text")? { + // Each paragraph keeps its own alignment through the retyping. + t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns); t.text = s.to_string(); } style(a, t)?; @@ -164,8 +197,8 @@ impl Automation { if a.get("text").is_some() { return Err(bad("images have no text")); } - if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) { - return Err(bad("font, size, bold, italic, color and align apply to text")); + if ["font", "size", "bold", "italic", "color", "align", "aligns"].iter().any(|k| a.get(k).is_some()) { + return Err(bad("font, size, bold, italic, color, align and aligns apply to text")); } if let Some(deg) = a.opt_int("rotate")? { if deg.rem_euclid(90) != 0 { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 1dae412e41..8c5ae96bc3 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -726,13 +726,10 @@ impl Automation { style.color = Some(comments::parse_color(c)?); } if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); + style.align = Some(content::parse_align(al)?); + } + if let Some(aligns) = content::opt_aligns(&a)? { + style.aligns = aligns; } // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1e009c1170..db521074d7 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -594,6 +594,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, }), &["doc", "page", "text"], )), @@ -615,6 +616,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, "image": { "type": "string", "description": "Replace the picture with this file." }, @@ -1362,6 +1364,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9fd5c4269a..5b8931c6af 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8424,3 +8424,26 @@ fn bookmark_list_reports_colour_and_style() { let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } + +/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box +/// again. Text edits take `aligns` for a paragraph's rows the same way. +#[test] +fn added_text_lines_take_their_own_alignments() { + let dir = workdir("paragraph-aligns"); + let mut a = auto(&dir); + let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]})); + let rect = |a: &mut Automation, text: &str| -> Vec { + let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); + let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); + line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() + }; + assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); + assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + // The whole box centred: the right-aligned line goes with it. + ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); + let right = rect(&mut a, "Right line"); + assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d39bb0fa91..29ccc4e8c9 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -116,6 +116,16 @@ pub struct AddedText { pub size: f64, pub color: [f64; 3], pub align: Align, + /// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index; + /// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, +} + +impl AddedText { + /// Paragraph `i`'s alignment. + pub fn paragraph_align(&self, i: usize) -> Align { + crate::paragraph_align::align_of(&self.aligns, i, self.align) + } } impl Default for AddedText { @@ -129,6 +139,7 @@ impl Default for AddedText { size: 12.0, color: [0.0; 3], align: Align::Left, + aligns: Vec::new(), } } } @@ -232,7 +243,7 @@ fn norm(r: [f64; 4]) -> [f64; 4] { /// The lines of a text item after wrapping to its box width. pub fn lines(t: &AddedText) -> Vec { - wrapped(t).into_iter().map(|(line, _)| line).collect() + wrapped(t).into_iter().map(|(line, ..)| line).collect() } #[cfg(test)] @@ -242,13 +253,13 @@ thread_local! { } /// [`lines`], each with whether its paragraph runs right to left (its first strong character -/// does). -fn wrapped(t: &AddedText) -> Vec<(String, bool)> { +/// does) and which paragraph it is in. +fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { #[cfg(test)] WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1))); let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); - for para in t.text.split('\n') { + for (index, para) in t.text.split('\n').enumerate() { // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); let thai = para.chars().any(is_thai); @@ -256,7 +267,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { && !rtl_script && let Ok(lines) = thai_lines(t, para, width) { - out.extend(lines.into_iter().map(|line| (line, false))); + out.extend(lines.into_iter().map(|line| (line, false, index))); continue; } let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; @@ -272,7 +283,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let candidate_w = if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; if !line.is_empty() && candidate_w > width { - out.push((std::mem::take(&mut line), rtl)); + out.push((std::mem::take(&mut line), rtl, index)); line = word.to_string(); line_w = measure(word); } else { @@ -280,7 +291,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { line_w = candidate_w; } } - out.push((line, rtl)); + out.push((line, rtl, index)); } out } @@ -354,7 +365,7 @@ pub fn first_undrawable(t: &AddedText) -> Option { return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); } // What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it. - wrapped(t).into_iter().find_map(|(line, rtl)| { + wrapped(t).into_iter().find_map(|(line, rtl, _)| { let (pieces, _) = shaped_layout(t, &line, rtl).ok()?; pieces.iter().find_map(|p| match p { Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), @@ -523,8 +534,26 @@ fn font_name(base: &str) -> String { const GLYPHS_PER_FONT: usize = 240; /// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, -/// its pieces in drawing order and its width at the item's size. -type LaidLine = (String, bool, Vec, f64); +/// its paragraph, its pieces in drawing order and its width at the item's size. +type LaidLine = (String, bool, usize, Vec, f64); + +/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word +/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and +/// every line of a justified paragraph but its last is stretched to the box's width. +fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) { + let paragraph = paragraphs.get(i).copied().unwrap_or(0); + let align = t.paragraph_align(paragraph); + let ends_paragraph = paragraphs.get(i + 1) != Some(¶graph); + let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + let x = match align { + // A right-to-left paragraph's unstretched last line ends at the right edge. + Align::Justify if rtl => r[2] - w - tw * spaces as f64, + Align::Left | Align::Justify => r[0], + Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, + Align::Right => r[2] - w, + }; + (x, tw) +} /// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs /// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), @@ -532,7 +561,7 @@ type LaidLine = (String, bool, Vec, f64); fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { let laid = wrapped(t) .into_iter() - .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w))) + .map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w))) .collect::, _>>()?; draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph) } @@ -554,7 +583,7 @@ fn draw_laid_shaped( // one of the fonts, so copy and search get each character exactly once. let mut keys: Vec<&ShapedCluster> = Vec::new(); let mut index: HashMap = HashMap::new(); - for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p { + for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p { Piece::Shaped(clusters) => clusters.as_slice(), Piece::Latin(_) => &[], }) { @@ -596,21 +625,14 @@ fn draw_laid_shaped( } let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); - for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() { + let paragraphs: Vec = laid.iter().map(|(_, _, p, ..)| *p).collect(); + for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() { // Shaped glyphs are painted in visual order. Keep the original line's typing order // explicitly, including marks, ligatures, digits and mixed-script punctuation. let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); out.extend(format!("/Span <>> BDC\n").bytes()); let spaces = line.matches(' ').count(); - // Justified: the spaces of every line but the last stretch to fill the box. - let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; - let x = match t.align { - // A right-to-left paragraph's unstretched last line ends at the right edge. - Align::Justify if *rtl => r[2] - w - tw * spaces as f64, - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; + let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl); let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; let mut pen = x; let mut current: Option<&str> = None; @@ -788,20 +810,14 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: let r = text_rect(t); let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); - let all = lines(t); - for (i, line) in all.iter().enumerate() { + let all = wrapped(t); + let paragraphs: Vec = all.iter().map(|(_, _, p)| *p).collect(); + for (i, (line, ..)) in all.iter().enumerate() { let w = t.family.width(line, t.size, t.bold, t.italic); - let x = match t.align { - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; // Baseline: 0.8 em below the line top. let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; - // Justified: word spacing makes every line but the last fill the box. - let spaces = line.matches(' ').count(); - let tw = - if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + // Justified: word spacing makes every line of a paragraph but its last fill the box. + let (x, tw) = placed(t, r, ¶graphs, i, w, line.matches(' ').count(), false); out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); out.extend(literal(&win_ansi(line))); out.extend_from_slice(b" Tj\n"); @@ -851,15 +867,12 @@ fn params(c: &Content) -> Dict { d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic))); d.set(b"Size".to_vec(), Object::Real(t.size)); d.set(b"Color".to_vec(), arr(&t.color)); - d.set( - b"Align".to_vec(), - Object::Int(match t.align { - Align::Left => 0, - Align::Center => 1, - Align::Right => 2, - Align::Justify => 3, - }), - ); + d.set(b"Align".to_vec(), Object::Int(align_code(t.align))); + // Each paragraph's own alignment, or null where it follows /Align. + let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align); + if !aligns.is_empty() { + d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect())); + } d.set(b"Rect".to_vec(), arr(&text_rect(t))); } Content::Image(i) => { @@ -883,6 +896,24 @@ fn params(c: &Content) -> Dict { d } +fn align_code(a: Align) -> i64 { + match a { + Align::Left => 0, + Align::Center => 1, + Align::Right => 2, + Align::Justify => 3, + } +} + +fn align_from(code: Option) -> Align { + match code { + Some(1) => Align::Center, + Some(2) => Align::Right, + Some(3) => Align::Justify, + _ => Align::Left, + } +} + fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); let rect: [f64; 4] = r.try_into().ok()?; @@ -890,20 +921,27 @@ fn parse(doc: &Document, d: &Dict) -> Option { b"Text" => { let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica")); let c = nums(doc, d.get(b"Color")); + let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let align = align_from(d.int(b"Align")); + // At most one entry per paragraph, whatever the file says. + let aligns = d + .get(b"Aligns") + .and_then(|a| { + doc.resolve(a) + .as_array() + .map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect()) + }) + .unwrap_or_default(); Some(Content::Text(AddedText { rect, - text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(), + text, family, bold, italic, size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0), color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] }, - align: match d.int(b"Align") { - Some(1) => Align::Center, - Some(2) => Align::Right, - Some(3) => Align::Justify, - _ => Align::Left, - }, + align, + aligns: crate::paragraph_align::tidy(aligns, align), })) } b"Image" => { diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index d4ebb73e42..0671e3ce32 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster { fn line(clusters: Vec, rtl: bool) -> LaidLine { let text = clusters.iter().map(|c| c.text.as_str()).collect(); let width = clusters.iter().map(|c| c.advance).sum::() * item().size; - (text, rtl, vec![Piece::Shaped(clusters)], width) + (text, rtl, 0, vec![Piece::Shaped(clusters)], width) } /// A glyph outline: a box on the baseline, `advance` em wide. @@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() }; let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() }; let mixed: LaidLine = - ("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); + ("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true); let laid = vec![mixed, rtl]; let mut doc = Document::new_empty(); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 630f12550b..f367b39d85 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -1031,6 +1031,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, reading_imag pub mod bidi; pub mod indic; mod linebreak; +pub mod paragraph_align; pub mod text; pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, diff --git a/crates/edit/src/paragraph_align.rs b/crates/edit/src/paragraph_align.rs new file mode 100644 index 0000000000..cf2023e615 --- /dev/null +++ b/crates/edit/src/paragraph_align.rs @@ -0,0 +1,135 @@ +//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks +//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can +//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment. + +use std::ops::RangeInclusive; + +use crate::added::Align; + +/// How many paragraphs `text` has (an empty text has one). +pub fn count(text: &str) -> usize { + text.bytes().filter(|b| *b == b'\n').count().saturating_add(1) +} + +/// The paragraph the byte position `at` of `text` is in. +fn paragraph_at(text: &str, at: usize) -> usize { + let bytes = text.as_bytes(); + bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count() +} + +/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's +/// paragraph; a selection ending just after a line break doesn't reach the next paragraph. +pub fn selected(text: &str, range: std::ops::Range) -> RangeInclusive { + let first = paragraph_at(text, range.start); + let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first }; + first..=last.max(first) +} + +/// Paragraph `i`'s alignment: its own, or `base`. +pub fn align_of(aligns: &[Option], i: usize, base: Align) -> Align { + aligns.get(i).copied().flatten().unwrap_or(base) +} + +/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others +/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments +/// compare equal however they were set; without one (the base is only guessed, as for an edited +/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way. +pub fn set(aligns: &[Option], count: usize, which: RangeInclusive, align: Align, base: Option) -> Vec> { + let mut out: Vec> = (0..count).map(|i| aligns.get(i).copied().flatten()).collect(); + for i in which { + if let Some(a) = out.get_mut(i) { + *a = Some(align); + } + } + match base { + Some(base) => tidy(out, base), + None => trimmed(out), + } +} + +/// `aligns` without entries that only repeat `base`, and without trailing `None`s. +pub fn tidy(mut aligns: Vec>, base: Align) -> Vec> { + for a in &mut aligns { + if *a == Some(base) { + *a = None; + } + } + trimmed(aligns) +} + +fn trimmed(mut aligns: Vec>) -> Vec> { + while aligns.last() == Some(&None) { + aligns.pop(); + } + aligns +} + +/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after +/// the change keep theirs, and the paragraphs the change made take the alignment of the first +/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or +/// after a centred paragraph makes centred ones). +pub fn remap(old: &str, new: &str, aligns: &[Option]) -> Vec> { + if aligns.is_empty() || old == new { + return aligns.to_vec(); + } + let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect()); + let shortest = o.len().min(n.len()); + let prefix = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest); + let suffix = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest - prefix); + let at = |i: usize| aligns.get(i).copied().flatten(); + let inserted_only = o.len() - prefix - suffix == 0; + let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) }; + let mut out: Vec> = (0..prefix).map(at).collect(); + out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix)); + out.extend((o.len() - suffix..o.len()).map(at)); + trimmed(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use Align::*; + + #[test] + fn a_selection_names_the_paragraphs_it_touches() { + let text = "One\nTwo\nThree"; + assert_eq!(count(text), 3); + assert_eq!(count(""), 1); + assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph"); + assert_eq!(selected(text, 5..6), 1..=1); + assert_eq!(selected(text, 2..9), 0..=2); + assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line"); + assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped"); + // Not a character boundary: still counted by bytes, never a panic. + assert_eq!(selected("é\nb", 1..1), 0..=0); + } + + #[test] + fn setting_one_paragraph_keeps_the_others_and_tidies() { + let one = set(&[], 3, 1..=1, Center, Some(Left)); + assert_eq!(one, [None, Some(Center)]); + assert_eq!(align_of(&one, 0, Left), Left); + assert_eq!(align_of(&one, 1, Left), Center); + assert_eq!(align_of(&one, 9, Right), Right); + assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all"); + assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored"); + assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped"); + assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays"); + } + + #[test] + fn alignments_follow_their_paragraphs_through_an_edit() { + let aligns = [Some(Center), None, Some(Right)]; + assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns); + // A line inserted before the last keeps the last right-aligned. + assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]); + // Enter inside the centred first paragraph: both halves are centred. + assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]); + // The middle paragraph deleted. + assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]); + // Everything replaced takes the first changed paragraph's alignment. + assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []); + assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]); + assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0549906dbe..692a21c8c2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6638,3 +6638,74 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_)))); assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } + +/// One row of an edited paragraph aligned on its own (#A: highlighting a line and aligning it): +/// the other rows stay where they were. +#[test] +fn one_row_of_a_paragraph_takes_its_own_alignment() { + let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + assert_eq!(block.editor_text, "One two three four five six seven\neight nine ten eleven twelve"); + let style = text::BlockStyle { aligns: vec![None, Some(added::Align::Right)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let row = |t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect; + assert!((row("One")[0] - block.rect[0]).abs() < 0.5, "the first row stays at the left: {:?}", row("One")); + assert!((row("eight")[2] - block.rect[2]).abs() < 1.0, "the second row ends at the right edge: {:?} vs {}", row("eight"), block.rect[2]); +} + +/// The same in a region of several styles: only the short first row is centred. +#[test] +fn one_row_of_a_styled_region_takes_its_own_alignment() { + let mut doc = mixed_region_page(); + let block = text::text_blocks(&doc, 0).unwrap()[1].clone(); + assert_eq!( + block.editor_text, + "About +Plain bold text. +Italic footer" + ); + let row = |lines: &[text::TextLine], t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect; + let before = text::text_lines(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![Some(added::Align::Center)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, 1, Some(&block.editor_text), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!((row(&lines, "Italic")[0] - row(&before, "Italic")[0]).abs() < 0.5, "the last row stays put"); + assert!((row(&lines, "Plain")[0] - row(&before, "Plain")[0]).abs() < 0.5, "the middle row stays put"); + let about = row(&lines, "About"); + let (mid, centre) = ((about[0] + about[2]) / 2.0, (block.rect[0] + block.rect[2]) / 2.0); + assert!(about[0] > row(&before, "About")[0] + 5.0 && (mid - centre).abs() < 1.5, "the first row is centred: {mid} vs {centre}"); +} + +/// Added text: each paragraph is laid out with its own alignment, a justified paragraph's last +/// line isn't stretched, and the alignments survive saving and reopening. +#[test] +fn added_text_paragraphs_keep_their_own_alignments() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 372.0, 700.0], + text: "Left line\nRight line\nCentred line".into(), + size: 12.0, + aligns: vec![None, Some(Align::Right), Some(Align::Center)], + ..AddedText::default() + }; + assert_eq!(text.paragraph_align(1), Align::Right); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + let doc = reopen(&doc); + let Content::Text(t) = &list_added(&doc)[0].content else { panic!() }; + assert_eq!(t.aligns, text.aligns, "saved with the item"); + let page = streams(&doc, 0).join("\n"); + let x_of = |s: &str| -> f64 { + let at = page.find(&format!("({s}) Tj")).unwrap(); + let tm = page[..at].rfind(" Tm").unwrap(); + let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums[0] + }; + let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false); + assert!((x_of("Left line") - 72.0).abs() < 0.01); + assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01); + assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01); + // A paragraph's own alignment the same as the box's is no entry at all. + let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left); + assert_eq!(tidy, [None, Some(Align::Right)]); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d003935314..3ce185a985 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -2565,10 +2565,10 @@ fn paragraph_separator(c: char) -> bool { /// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); -/// those pieces join without a space. -fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { +/// those pieces join without a space. Each line comes with the `\n`-separated row it is from. +fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, usize)> { let mut out = Vec::new(); - for row in text.split('\n') { + for (index, row) in text.split('\n').enumerate() { let mut line = String::new(); let start = out.len(); for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) { @@ -2579,7 +2579,7 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { (false, _) => format!("{line}{piece}"), }; if !line.is_empty() && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); + out.push((std::mem::take(&mut line), index)); line = piece.to_string(); } else { line = candidate; @@ -2587,7 +2587,7 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { } } if !line.is_empty() || out.len() == start { - out.push(line); + out.push((line, index)); } } out @@ -2616,6 +2616,9 @@ pub struct BlockStyle { /// Fill colour (RGB 0–1). pub color: Option<[f64; 3]>, pub align: Option, + /// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None` + /// and rows past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, /// Draw a line under each line of text. pub underline: Option, /// Line spacing as a multiple of the font size (1.2 is ordinary). @@ -2926,8 +2929,10 @@ fn rewrite_block_from( } block_ops.extend(state.ops()); block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + // Each row's alignment: its own, or the paragraph's. + let align_of = |row: usize| style.aligns.get(row).copied().flatten().or(align); // Alignment: each line's offset from the left edge, in text space. - let offset = |line: &str| -> f64 { + let offset = |line: &str, align: Option| -> f64 { let free = (width - advance(line)).max(0.0) / k; match align { Some(crate::added::Align::Center) => free / 2.0, @@ -2943,7 +2948,6 @@ fn rewrite_block_from( // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a // generated Type 3 font, a subset without a space) the extra space follows each space as a // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), None => type3.is_none(), @@ -2951,12 +2955,14 @@ fn rewrite_block_from( let mut x = 0.0; let mut tw_set = 0.0; let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { + for (i, (line, row)) in wrapped.iter().enumerate() { + let line_align = align_of(*row); + let justify = line_align == Some(crate::added::Align::Justify); let spaces = line.matches(' ').count(); let tw = if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 }; // A stretched line fills the width from the left edge. - let dx = if tw > 0.0 { 0.0 } else { offset(line) }; + let dx = if tw > 0.0 { 0.0 } else { offset(line, line_align) }; if i > 0 || dx != 0.0 { block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); } @@ -3349,24 +3355,28 @@ mod tests { assert!(!same_base_font("Arial", "")); } + fn rows(lines: Vec<(String, usize)>) -> Vec { + lines.into_iter().map(|(line, _)| line).collect() + } + #[test] fn paragraph_wrapping_keeps_ideographic_spaces() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]); - assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); + assert_eq!(rows(wrap("A  B C", 4.0, width)), ["A  B", "C"]); + assert_eq!(rows(wrap("A\u{a0}B C", 3.0, width)), ["A B", "C"]); + assert_eq!(rows(wrap("A\u{2028}B C", 3.0, width)), ["A B", "C"]); + assert_eq!(rows(wrap(" \tA B\r\nC ", 3.0, width)), ["A B", "C"]); } #[test] fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + assert_eq!(rows(wrap("日本語の文章です。", 3.0, width)), ["日本語", "の文章", "です。"]); // The comma can't begin a row, so the character before it moves down with it. - assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + assert_eq!(rows(wrap("あいう、えお", 3.0, width)), ["あい", "う、え", "お"]); // Pieces of one word join without a space; separate words keep theirs. - assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); - assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + assert_eq!(rows(wrap("Edit PDFを編集", 6.0, width)), ["Edit", "PDFを編集"]); + assert_eq!(rows(wrap("一行目 二行目", 4.0, width)), ["一行目 ", "二行目"]); } #[test] diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index a775594944..11ae6c5a2c 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -728,6 +728,9 @@ pub(super) fn rewrite_styled_block( let mut rows: Vec> = Vec::new(); // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). let mut row_ranges = Vec::new(); + // Each row's line in the draft (its `\n`-separated part), which [`BlockStyle::aligns`] counts. + let mut row_lines: Vec = Vec::new(); + let mut draft_line = 0usize; let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; @@ -737,6 +740,8 @@ pub(super) fn rewrite_styled_block( let Some(item) = chars.get(at) else { break }; if item.0 == '\n' { row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); + draft_line = draft_line.saturating_add(1); rows.push(std::mem::take(&mut row)); row_width = 0.0; at += 1; @@ -760,6 +765,7 @@ pub(super) fn rewrite_styled_block( row.pop(); } row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); rows.push(std::mem::take(&mut row)); row_width = 0.0; row_start = at; @@ -770,6 +776,7 @@ pub(super) fn rewrite_styled_block( } if !row.is_empty() { row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); rows.push(row); } let streams = content_streams(doc, &p.dict)?; @@ -869,10 +876,12 @@ pub(super) fn rewrite_styled_block( continue; } let w = measured.get(row_index).copied().unwrap_or(0.0); - let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); + // The row's own alignment, or the paragraph's. + let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align); + let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); - let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; - let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; + let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] }; + let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width }; let mut x = left + match align { crate::added::Align::Center => (available - w) / 2.0, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..caeddb02ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,6 +56,7 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{text_rect as added_text_rect, text_width as added_text_width}; +pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..7e2542c5d5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6369,6 +6369,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); let after = session.get(id).unwrap(); @@ -6408,6 +6409,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), }, }, Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] }, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index c99c2cd787..c9d2c33aba 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -66,9 +66,12 @@ pub struct TextDraft { /// Display space. pub rect: [f64; 4], pub text: String, - /// The box's formatting; its `text` is what the page has (empty for new text). + /// The box's formatting; its `text` and `rect` are what the page has (empty for new text), and + /// its `aligns` follow `text` as it is typed. pub style: AddedText, pub focus: bool, + /// The selected bytes of `text` (empty: the caret), for aligning the lines it touches. + pub selection: Option>, /// The text the box's own Undo history starts from ([`crate::edit_text_ui::forget_typing_before`]). pub typing_from: Option, } @@ -404,7 +407,7 @@ pub(crate) fn page_input( let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); let rect = [at[0], at[1] - style.size * 1.2, (at[0] + 200.0).min(page_width), at[1]]; style.rect = rect; - cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, typing_from: None }); + cv.draft = Some(TextDraft { page, index: None, rect, text: String::new(), style, focus: true, selection: None, typing_from: None }); consumed = true; if typed.is_some() { view.pending_edit = typed; @@ -442,7 +445,16 @@ pub(crate) fn page_input( if let Some(edit) = cv.finish_draft(added) { view.pending_edit = Some(edit); } - cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true, typing_from: None }); + cv.draft = Some(TextDraft { + page, + index: Some(i), + rect: t.rect, + text: t.text.clone(), + style: t.clone(), + focus: true, + selection: None, + typing_from: None, + }); cv.selected = Some((page, i)); consumed = true; return consumed; @@ -552,6 +564,11 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad let (mut commit, mut cancel, mut done) = (false, false, false); let mut undrawable = None; let memo = &view.content.undrawable; + // Clicks outside the document (the Format text panel) keep the text open, as they keep a + // paragraph being edited open. + // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a + // pen lifted away). + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). @@ -570,28 +587,38 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); - // Laid out as the page draws it, so opening the box doesn't show it realigned. - // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter. - let halign = match t.style.align { - pdfcraft_engine::TextAlign::Center => egui::Align::Center, - pdfcraft_engine::TextAlign::Right => egui::Align::Max, - pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, - }; + let color = Color32::from_rgb(cr, cg, cb); + // Laid out as the page draws it, each paragraph with its own alignment, so opening the + // box doesn't show it realigned. + let style = t.style.clone(); + let line_font = font.clone(); + let mut layouter = + |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| aligned_galley(ui, text.as_str(), &style, &line_font, color, wrap); + let before = t.text.clone(); let input = input_id(); crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text); ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(font) - .desired_width(r.width().max(60.0)) - .desired_rows(1) - .horizontal_align(halign) - .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text(tl!("Type text")) - .id(input), - ); + let output = egui::TextEdit::multiline(&mut t.text) + .font(font) + .desired_width(r.width().max(60.0)) + .desired_rows(1) + .layouter(&mut layouter) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) + .text_color(color) + .hint_text(tl!("Type text")) + .id(input) + .show(ui); + let resp = output.response; + if let Some(cursor) = output.cursor_range { + let chars = cursor.as_sorted_char_range(); + let byte = |index: usize| t.text.char_indices().nth(index).map_or(t.text.len(), |(at, _)| at); + t.selection = Some(byte(chars.start.into())..byte(chars.end.into())); + } + // Each paragraph keeps its own alignment through the typing. + if t.text != before { + t.style.aligns = pdfcraft_engine::paragraph_align::remap(&before, &t.text, &t.style.aligns); + } if t.focus { resp.request_focus(); t.focus = false; @@ -613,7 +640,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Pressing a button takes the focus from the text: that is not a click away. let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); - commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; + commit = done || resp.lost_focus() && !on_buttons && !outside && !view.objects.hold_editor; }); // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). undrawable = self::undrawable(t, memo); @@ -637,6 +664,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad /// updated, and text emptied by retyping removed. Empty new text adds nothing. fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { let text = d.text.trim_end().to_string(); + let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); + let d = TextDraft { style: AddedText { aligns, ..d.style }, ..d }; match d.index { None if text.trim().is_empty() => None, None => { @@ -658,6 +687,50 @@ pub(crate) fn input_id() -> egui::Id { egui::Id::new("added-text-input") } +/// `text` laid out as added text with `style` is drawn: each paragraph (line typed with Enter) wrapped +/// to `wrap` and placed by its own alignment, a justified one with every row but its last +/// stretched. +fn aligned_galley(ui: &egui::Ui, text: &str, style: &AddedText, font: &egui::FontId, color: Color32, wrap: f32) -> std::sync::Arc { + use pdfcraft_engine::TextAlign; + let job = |text: &str, align: TextAlign| { + let mut job = egui::text::LayoutJob::simple(text.to_owned(), font.clone(), color, wrap); + job.justify = align == TextAlign::Justify; + job + }; + let uniform = text.split('\n').enumerate().all(|(i, _)| style.paragraph_align(i) == style.align); + if uniform && style.align != TextAlign::Center && style.align != TextAlign::Right { + return ui.fonts_mut(|f| f.layout_job(job(text, style.align))); + } + // Each paragraph on its own (egui aligns a whole job one way), its rows moved within the + // box's width, then joined in order: the caret and selection see one text. + let mut galleys = Vec::new(); + let mut start = 0; + for (i, paragraph) in text.split('\n').enumerate() { + let end = start + paragraph.len(); + // The last paragraph keeps a trailing break, as egui's own split does. + let last = end + 1 == text.len(); + let piece = if last { text.get(start..).unwrap_or_default() } else { paragraph }; + let mut galley = ui.fonts_mut(|f| f.layout_job(job(piece, style.paragraph_align(i)))); + let g = std::sync::Arc::make_mut(&mut galley); + for placed in &mut g.rows { + let free = (wrap - placed.row.size.x).max(0.0); + placed.pos.x = match style.paragraph_align(i) { + TextAlign::Center => free / 2.0, + TextAlign::Right => free, + TextAlign::Left | TextAlign::Justify => 0.0, + }; + } + g.rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(g.rect.width().max(wrap), g.rect.height())); + galleys.push(galley); + if last { + break; + } + start = end + 1; + } + let whole = std::sync::Arc::new(egui::text::LayoutJob::simple(text.to_owned(), font.clone(), color, wrap)); + std::sync::Arc::new(egui::Galley::concat(whole, &galleys, ui.ctx().pixels_per_point())) +} + /// How to use the tools, under the Add content list. pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) { ui.label( diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f2c5c55151..f9074af30f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -356,7 +356,12 @@ impl LineEditor { family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), size: (l.size != o.size).then_some(l.size), color: (l.color != o.color).then_some(l.color), - align: (l.align != o.align).then_some(l.align), + // The whole paragraph's alignment when it was picked, or when the rows' own were + // dropped for it (the rows are where those put them, so leaving it out keeps them there). + align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align), + // Rows aligned on their own, sent with every rewrite of the paragraph while it is open: + // nothing in the page records them, so a rewrite without them would undo them. + aligns: if l.aligns != o.aligns || self.text != self.original { l.aligns.clone() } else { Vec::new() }, underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline), line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0) .then_some(self.extras.line_spacing), @@ -403,9 +408,17 @@ impl LineEditor { self.source_rect = block.rect.map(|v| v as f32); self.multiline = block.rows.len() > 1; self.source = Some((block.clone(), lines)); - self.text = block.editor_text.clone(); + let before = std::mem::replace(&mut self.text, block.editor_text.clone()); + self.follow_rows(&before); self.original = self.text.clone(); } + + /// The text changed from `before`: rows aligned on their own keep their alignment. + pub(crate) fn follow_rows(&mut self, before: &str) { + for look in [&mut self.look, &mut self.look0] { + look.aligns = pdfcraft_engine::paragraph_align::remap(before, &self.text, &look.aligns); + } + } } /// The look shown for a paragraph: the family and weight guessed from its PDF font name. @@ -1539,7 +1552,14 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: let g = Arc::make_mut(&mut galley); let mut bounds = Rect::NOTHING; let mut meshes = Rect::NOTHING; + // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where + // the rewrite will put them, in the paragraph's width. + let mut line = 0usize; for (index, placed) in g.rows.iter_mut().enumerate() { + let own = ed.look.aligns.get(line).copied().flatten(); + if placed.ends_with_newline { + line = line.saturating_add(1); + } let (last_baseline, last_rect) = sources.last().copied().unwrap_or((first_baseline, first_rect)); let (baseline, source_rect) = sources .get(index) @@ -1552,7 +1572,14 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: let base_y = (block.rect[3] - baseline) as f32 * scale; let row = Arc::make_mut(&mut placed.row); placed.pos.y = base_y - ascent; - placed.pos.x = if centered { (width - row.size.x) / 2.0 } else { (source_rect[0] - block.rect[0]) as f32 * scale }; + let free = (width - row.size.x).max(0.0); + placed.pos.x = match own { + Some(pdfcraft_engine::TextAlign::Center) => free / 2.0, + Some(pdfcraft_engine::TextAlign::Right) => free, + Some(pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify) => 0.0, + None if centered => (width - row.size.x) / 2.0, + None => (source_rect[0] - block.rect[0]) as f32 * scale, + }; for glyph in &mut row.glyphs { let dy = ascent - glyph.pos.y; glyph.pos.y = ascent; @@ -1578,7 +1605,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { // Clicks outside the document (the Format text panel) keep the paragraph open. - let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a + // pen lifted away). + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; @@ -1692,6 +1721,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let galley = editor_galley(ui, &snapshot, text.as_str(), scale, wrap_width); if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; + let before = ed.text.clone(); let input = input_id(); forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original); let output = egui::TextEdit::multiline(&mut ed.text) @@ -1719,6 +1749,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } let r = output.response; if r.changed() { + ed.follow_rows(&before); // The next pass measures the updated draft with the Apply/Save rewrite. ctx.request_repaint(); } diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 749f09dcc2..6071b76b67 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -39,6 +39,44 @@ pub(crate) struct Fonts<'a> { pub shown: String, } +/// Text being typed in, for the alignment buttons: with some of it selected, they show and set the +/// alignment of the paragraphs (lines between `\n`s) the selection touches; with none, the box's. +pub(crate) struct Paragraphs<'a> { + pub text: &'a str, + /// A byte range of `text`; empty or `None` is nothing selected. + pub selection: Option>, + /// The box's own alignment is known (added text), so a paragraph set to it keeps no entry of its + /// own. An edited paragraph's alignment is only guessed from where its rows sit: its rows keep + /// every alignment set. + pub tidy: bool, +} + +impl Paragraphs<'_> { + /// The paragraphs a selection touches, or `None` with nothing selected. + fn selected(&self) -> Option> { + self.selection.clone().filter(|r| !r.is_empty()).map(|r| pdfcraft_engine::paragraph_align::selected(self.text, r)) + } + + /// The alignment the buttons show: the first selected paragraph's, or the box's. + fn shown(&self, look: &AddedText) -> TextAlign { + self.selected().map_or(look.align, |which| look.paragraph_align(*which.start())) + } + + /// `align` picked: for the selected paragraphs, or the whole box (dropping theirs). + fn choose(&self, look: &mut AddedText, align: TextAlign) { + match self.selected() { + Some(which) => { + let count = pdfcraft_engine::paragraph_align::count(self.text); + look.aligns = pdfcraft_engine::paragraph_align::set(&look.aligns, count, which, align, self.tidy.then_some(look.align)); + } + None => { + look.align = align; + look.aligns.clear(); + } + } + } +} + /// What the panel changed: the style (in place) and a font picked from the list. #[derive(Default)] pub(crate) struct Outcome { @@ -70,7 +108,14 @@ fn number(v: f64) -> String { s.trim_end_matches('0').trim_end_matches('.').to_string() } -pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome { +pub(crate) fn panel( + ui: &mut egui::Ui, + t: &Tokens, + look: &mut AddedText, + extras: Option<&mut Extras>, + fonts: Fonts, + paragraphs: Option, +) -> Outcome { let before = (look.clone(), extras.as_deref().copied()); let mut out = Outcome::default(); widgets::section_title(ui, tl!("Format text")); @@ -148,7 +193,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider)); planned(ui, "list", tl!("Bulleted list")); planned(ui, "list-ordered", tl!("Numbered list")); - let current = match look.align { + let shown = paragraphs.as_ref().map_or(look.align, |p| p.shown(look)); + let current = match shown { TextAlign::Left => "align-left", TextAlign::Center => "align-center", TextAlign::Right => "align-right", @@ -164,8 +210,14 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: (TextAlign::Right, "align-right", tl!("Align right")), (TextAlign::Justify, "align-justify", tl!("Justify")), ] { - if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() { - look.align = a; + if icons::button(ui, icon, 30.0, shown == a, tip).clicked() { + match ¶graphs { + Some(p) => p.choose(look, a), + None => { + look.align = a; + look.aligns.clear(); + } + } ui.close(); } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7d16bc3407..4285f1da31 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -534,7 +534,9 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { .map(|f| (f.resource, f.face)) .collect(); let shown = ed.font_shown(&sources); - let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }); + // A selection aligns the rows it touches; the caret alone, the whole paragraph. + let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false }; + let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs)); let mut changed = out.changed; match out.font { Some(FontChoice::KeepSource) => { @@ -561,6 +563,21 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } return; } + // Typing added text: the box's formatting, and alignment for the lines a selection touches. + if let Some(i) = app.active_ids().map(|(i, _)| i) + && let Some(draft) = app.views[i].content.draft.clone() + { + if draft.index.is_none() && app.quick_tool == crate::QuickTool::AddText { + crate::content_ui::hint(ui, t); + } + let mut style = draft.style.clone(); + let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true }; + added_text_format(ui, t, &mut style, Some(paragraphs)); + if style != draft.style { + app.restyle_draft(i, style); + } + return; + } let image = app.active_ids().and_then(|(i, id)| { let (page, index) = app.views[i].content.selected?; let doc = app.session.get(id)?; @@ -593,34 +610,65 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { match selected { Some((page, index, text)) => { let mut style = text.clone(); - added_text_format(ui, t, &mut style); + added_text_format(ui, t, &mut style, None); if style != text { // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later. if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) { - app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style }; + app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..style }; } } } None if app.quick_tool == crate::QuickTool::AddText => { crate::content_ui::hint(ui, t); let mut style = app.text_style.clone(); - added_text_format(ui, t, &mut style); + added_text_format(ui, t, &mut style, None); app.text_style = style; } None => {} } } -/// Format text for added text: the standard families, size, colour, style and alignment. -fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) { +/// Format text for added text: the standard families, size, colour, style and alignment +/// (of the lines `paragraphs` selects, while the text is being typed). +fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) { let shown = style.family.label().to_string(); - let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }); + let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs); if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { style.family = family; } } impl PdfCraftApp { + /// Format text changed while added text is being typed in view `index`: the box takes the new + /// style at once and stays open. Text already on the page is updated with what has been typed, + /// as one undoable change; new text waits until it is finished. Text typed after this styling + /// starts from it too. + pub(crate) fn restyle_draft(&mut self, index: usize, style: pdfcraft_engine::AddedText) { + let Some(view) = self.views.get_mut(index) else { return }; + // Text the standard fonts can't draw stays in the box, unwritten, until it is corrected (#125). + let blocked = view.content.blocked().is_some(); + let Some(d) = view.content.draft.as_mut() else { return }; + let committed = d.style.clone(); + // `text` and `rect` stay what the page has; the rest is the new style. + d.style = pdfcraft_engine::AddedText { text: committed.text.clone(), rect: committed.rect, ..style }; + d.focus = true; + self.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], aligns: Vec::new(), ..d.style.clone() }; + let (page, item) = (d.page, d.index); + let text = d.text.trim_end().to_string(); + let Some(item) = item.filter(|_| !text.trim().is_empty() && !blocked) else { return }; + let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); + let updated = pdfcraft_engine::AddedText { text, rect: d.rect, aligns, ..d.style.clone() }; + if updated == committed { + return; + } + if self.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index: item, content: pdfcraft_engine::AddedContent::Text(updated.clone()) }) + && let Some(d) = self.views.get_mut(index).and_then(|v| v.content.draft.as_mut()) + { + // What the page has now. The aligns stay those of the text in the box. + d.style = pdfcraft_engine::AddedText { aligns: std::mem::take(&mut d.style.aligns), ..updated }; + } + } + /// Apply the open paragraph's formatting as one change; the paragraph stays open. pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index de06d44b15..0476064618 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -883,3 +883,35 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); } + +/// Bug A for added text: with one line of the box selected, an alignment applies to that line +/// only, the box stays open, and Undo puts it back and closes the box. +#[test] +fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 220.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Line one\nLine two".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); + h.run_steps(2); + let button = h.get_by_label("Alignment").rect().center(); + click(&mut h, button); + let item = h.get_by_label("Align right").rect().center(); + click(&mut h, item); + let aligns = |h: &Harness<'static, PdfCraftApp>| match &added(h)[0].content { + pdfcraft_engine::AddedContent::Text(t) => (t.align, t.aligns.clone()), + _ => panic!(), + }; + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)])); + assert!(h.state().views[0].content.draft.is_some(), "the box stays open"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(4); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back"); + assert!(h.state().views[0].content.draft.is_none(), "and closes the box"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 2fcf67dc42..d51f7f551f 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6074,3 +6074,56 @@ fn undo_while_typing_takes_back_the_typing_first() { assert_eq!(ed.text, original, "the typing is taken back"); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the document is untouched"); } + +/// A paragraph of a short, a wide and a short row, opened in the paragraph editor. +fn three_row_paragraph() -> Harness<'static, PdfCraftApp> { + let bytes = paragraph_drag_fixture("BT /F1 10 Tf 12 TL 60 400 Td (One two) Tj T* (three four five six seven eight) Tj T* (nine ten) Tj ET", 0, 1); + let mut h = paragraph_drag_harness(bytes, 0); + let (at, _) = paragraph_drag_points(&h, "One two three four five six seven eight nine ten", [0.0, 0.0]); + click_at(&mut h, at); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); + h +} + +/// Format text's alignment flyout, by pointer. +fn align_by_pointer(h: &mut Harness<'static, PdfCraftApp>, choice: &str) { + let button = h.get_by_label("Alignment").rect().center(); + click_at(h, button); + let item = h.get_by_label(choice).rect().center(); + click_at(h, item); +} + +fn row_rect(h: &Harness<'static, PdfCraftApp>, starts: &str) -> [f64; 4] { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_lines(0).into_iter().find(|l| l.text.starts_with(starts)).expect("the row").rect +} + +/// Bug A: with one row of a paragraph selected, an alignment applies to that row only. +/// Bug C: Undo then puts the paragraph back and closes the editor, instead of leaving it open on +/// a text the page no longer has. +#[test] +fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() { + let mut h = three_row_paragraph(); + let first = row_rect(&h, "One"); + let wide = row_rect(&h, "three"); + let last = row_rect(&h, "nine"); + // Select the last row. + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_pointer(&mut h, "Align right"); + { + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit text")); + assert!(s.views[0].line_editor.is_some(), "the paragraph stays open"); + } + let moved = row_rect(&h, "nine"); + assert!((row_rect(&h, "One")[0] - first[0]).abs() < 0.5, "the first row stays put"); + assert!((moved[2] - wide[2]).abs() < 1.0 && moved[0] > last[0] + 20.0, "the last row is right-aligned: {moved:?}"); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "the alignment is undone"); + assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed"); + assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4af0cd98c2..f0deaf5b16 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1962,8 +1962,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] -notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581)" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/edit/src/tests.rs::one_row_of_a_paragraph_takes_its_own_alignment", "crates/edit/src/tests.rs::one_row_of_a_styled_region_takes_its_own_alignment", "crates/edit/src/tests.rs::added_text_paragraphs_keep_their_own_alignments", "crates/ui-egui/tests/it/editing.rs::aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back", "crates/ui-egui/tests/it/content.rs::aligning_a_selected_line_of_added_text_aligns_only_that_line", "crates/automation/tests/it/automation.rs::added_text_lines_take_their_own_alignments"] +notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581). With text selected, Format text aligns only the lines (rows between line breaks) the selection touches; with only the caret, the whole paragraph or box (aligns in text_edit, page_add_text and content_update). Added text keeps each line's alignment in its /PCAdded parameters, and a justified line's last row is not stretched. Missing: an edited paragraph's per-row alignment is not recorded in the page, so after it is closed and reopened a later rewrite of it can put that row back to the paragraph's alignment (as whole-paragraph justify is lost, #2508/#2515); aligning a row can regroup the paragraph, and the editor then shows only the part it groups first; the added-text editor stretches justified lines with egui's own spacing, close to but not exactly as written" [[feature]] id = "edit.line-paragraph-spacing" From 1f29575d34cda035b52997c14e31253ff8dd7f3e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:42:12 +1100 Subject: [PATCH 07/82] M7: align a one-row paragraph across the column it sits in Centring one highlighted line of a paragraph moves it out of the paragraph's rows, so the next alignment of it saw a one-row paragraph and aligned it in its own box: Left left it where it was, and since a single row grows to the page's margin, each Centre moved it further right. An alignment for a one-row paragraph is now laid out across the column of text it sits in (the left edge the page's other text uses, to the furthest right those rows reach), or its own box when no column holds it. --- crates/edit/src/tests.rs | 33 +++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 12 +++++++++++- crates/edit/src/text_regions.rs | 25 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 692a21c8c2..ecac4c782c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6709,3 +6709,36 @@ fn added_text_paragraphs_keep_their_own_alignments() { let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left); assert_eq!(tidy, [None, Some(Align::Right)]); } + +/// A one-row title over a body column: an alignment picked for it is measured across the column. +/// Left puts it on the column's left edge, and centring it again leaves it where it is, rather +/// than aligning it in its own box (a no-op) or toward the page's margin (moving it right each +/// time). It is aligned as the editor aligns a selected row, in whichever paragraph holds it. +#[test] +fn a_lone_row_aligns_across_the_column_it_sits_in() { + let mut doc = text_page( + "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let align = |doc: &mut Document, a: added::Align| { + let lines = text::text_lines(doc, 0).unwrap(); + let line = lines.iter().position(|l| l.text == "Title").unwrap(); + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.lines.contains(&line)).unwrap(); + // The title is the paragraph's first row. + let style = text::BlockStyle { aligns: vec![Some(a)], ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + align(&mut doc, added::Align::Center); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + align(&mut doc, added::Align::Center); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); + align(&mut doc, added::Align::Left); + assert!((title(&doc)[0] - column[0]).abs() < 0.5, "left: back on the column's edge, {:?} vs {column:?}", title(&doc)); + align(&mut doc, added::Align::Right); + assert!((title(&doc)[2] - column[1]).abs() < 1.0, "right: on the column's right edge, {:?} vs {column:?}", title(&doc)); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 3ce185a985..e8e80d29f5 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -24,7 +24,7 @@ use crate::EditError; mod regions; #[path = "text_session.rs"] mod session; -use regions::{group_blocks, is_centred, rewrite_styled_block}; +use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; pub use session::TextSession; /// One line of existing text. @@ -2678,6 +2678,16 @@ fn rewrite_block_from( ) -> Result { let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + // An alignment for a one-row paragraph is laid out across the column it sits in, as a + // paragraph of that width moved to its left edge. + let framed; + let style = match alignment_frame(&lines, &b, style) { + Some([left, right]) => { + framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; + &framed + } + None => style, + }; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); // One clipping run is enough: rewriting the paragraph would drop its mask with the rest. let mut dropped = coincident_ops(&lines, &members); diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 11ae6c5a2c..d9465cdffd 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -44,6 +44,31 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { Some(best as f64 / 4.0) } +/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of +/// text it sits in (the left edge the rest of the page's text uses, to the furthest right those +/// rows reach) when that column holds it, otherwise its own box. A lone row's own box is no frame +/// to align in: left-aligning a title centred on the page left it where it was, and since a single +/// row grows to the page's margin, centring it again moved it right each time. `None` when no +/// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a +/// move of its own. +pub(super) fn alignment_frame(lines: &[TextLine], block: &TextBlock, style: &BlockStyle) -> Option<[f64; 2]> { + let asked = style.align.is_some() || style.aligns.iter().any(Option::is_some); + if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { + return None; + } + let own = [block.rect[0], block.rect[2]]; + let tolerance = block.size.max(1.0) * 0.5; + let Some(left) = neighbour_left(lines, block) else { return Some(own) }; + let right = lines + .iter() + .enumerate() + .filter(|(i, line)| !block.lines.contains(i) && (line.rect[0] - left).abs() < tolerance) + .map(|(_, line)| line.rect[2]) + .fold(f64::NEG_INFINITY, f64::max); + let holds = right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance; + Some(if holds { [left, right] } else { own }) +} + /// Whether a block reads as centred, and so must keep its centre when it grows or wraps. /// /// Several ragged rows whose left edges disagree while their centres agree are centred. A one row From 5c61312d81157e9092fa8aadebf8fa7122d57713 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:05:23 +1100 Subject: [PATCH 08/82] fix(ui): Redo after Undo in a page text box puts the typing back With typing undone in a paragraph or added text box, its text matches the page again, so Undo and Redo went to the document. Redo then closed the box and redid a document change (or nothing), instead of putting the typing back as the box's own Redo did before. And Shift+Cmd+Z is also Cmd+Z to the Undo shortcut, so even a Redo the box kept was taken as a document Undo. Redo stays with the box while its own history has typing to put back (the test egui makes when it handles the key), and Shift+Cmd+Z is never a document Undo. --- crates/ui-egui/src/commands.rs | 10 ++++++++-- crates/ui-egui/src/editing.rs | 21 ++++++++++++++++++++ crates/ui-egui/tests/it/content.rs | 30 +++++++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 31 ++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ee19c168eb..fb4d6f1b6f 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -727,8 +727,14 @@ impl PdfCraftApp { continue; } // A box typed into on the page (a paragraph being edited, added text) keeps Undo and - // Redo only while it holds typing the page doesn't have; then they reach the document. - let page_box = matches!(spec.id, "edit.undo" | "edit.redo") && self.page_box_focused(ctx) && !self.page_typing_unapplied(); + // Redo only while it holds typing the page doesn't have, or Redo has typing to put + // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a + // Redo the box keeps is never taken as Undo. + let page_box = matches!(spec.id, "edit.undo" | "edit.redo") + && self.page_box_focused(ctx) + && !self.page_typing_unapplied() + && !(spec.id == "edit.redo" && self.page_box_can_redo(ctx)) + && !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift)); if typing && !spec.in_text && !page_box { continue; } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6f16837242..2b31091084 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -293,6 +293,27 @@ impl PdfCraftApp { focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) } + /// Whether the focused box on the page has typing its own Redo would put back: after Undo in + /// the box restores what the page has, Redo still belongs to the box, not the document. The + /// same test egui makes when it handles the key. + pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool { + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + let focused = ctx.memory(|m| m.focused()); + let (id, text) = if focused == Some(crate::edit_text_ui::input_id()) + && let Some(ed) = &view.line_editor + { + (crate::edit_text_ui::input_id(), &ed.text) + } else if focused == Some(crate::content_ui::input_id()) + && let Some(d) = &view.content.draft + { + (crate::content_ui::input_id(), &d.text) + } else { + return false; + }; + egui::TextEdit::load_state(ctx, id) + .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.clone())))) + } + /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't /// have yet: Undo and Redo keys take it back in the box first. pub(crate) fn page_typing_unapplied(&self) -> bool { diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0476064618..7edb13ef6a 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -915,3 +915,33 @@ fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back"); assert!(h.state().views[0].content.draft.is_none(), "and closes the box"); } + +/// The same in an added text box: Redo after Undo puts the typing back in the box, and the +/// document's own Redo stays waiting. +#[test] +fn redo_after_undoing_typing_in_added_text_puts_it_back() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + let bold = pdfcraft_engine::AddedText { bold: true, ..text }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); + assert!(h.state_mut().execute("edit.undo")); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed")); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts the typing back"); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index d51f7f551f..b19f69cb46 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6127,3 +6127,34 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() { assert!(s.views[0].line_editor.is_none(), "the editor closed with the step it showed"); assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back"); } + +/// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document, +/// whose own Redo (a formatting change waiting here) must stay waiting. +#[test] +fn redo_after_undoing_typing_puts_the_typing_back() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + // Undo the bold (closing the editor), so the document has it to redo. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text")); + open_paragraph(&mut h); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("still typing"); + assert_eq!(ed.text, typed, "Redo puts the typing back"); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); +} From a44b850dac96fb4a375e9c54f693123fafed54f4 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:23:58 +1100 Subject: [PATCH 09/82] fix(ui): a narrow added-text box keeps its text at its own edge The editor is at least 60 points wide on screen, so a short box can be clicked into. With the box's alignment applied, right-aligned or centred text in a box narrower than that was placed against the wider editor's edge, past the box's right edge. The editor now reaches past a narrow box away from the edge its text keeps, so the text stays where the page draws it. --- crates/ui-egui/src/content_ui.rs | 17 +++++++++++++++-- crates/ui-egui/tests/it/content.rs | 23 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index c9d2c33aba..58f954a895 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -559,6 +559,19 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } let r = screen_rect(&xf, info, page, draft.rect); let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect)); + // At least 60 points wide, so a short box can still be clicked into. Where that is wider than + // the box, the editor reaches past it away from the edge its text keeps (the right edge for + // right-aligned text, both halves for centred), so the text stays where the page draws it. + // shortcut: lines aligned differently from each other in a box under 60 points keep the + // editor's left edge; anchor each line on its own if such boxes turn out to matter. + let width = r.width().max(60.0); + let uniform = (0..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == draft.style.align); + let anchor = match draft.style.align { + pdfcraft_engine::TextAlign::Center if uniform => 0.5, + pdfcraft_engine::TextAlign::Right if uniform => 1.0, + _ => 0.0, + }; + let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0); let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index)); let tokens = Tokens::get(ctx); let (mut commit, mut cancel, mut done) = (false, false, false); @@ -575,7 +588,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad egui::Area::new(egui::Id::new(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) - .fixed_pos(r.min) + .fixed_pos(at) .constrain_to(view.viewport_rect()) .constrain(false) .show(ctx, |ui| { @@ -601,7 +614,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad ui.spacing_mut().item_spacing.x = 4.0; let output = egui::TextEdit::multiline(&mut t.text) .font(font) - .desired_width(r.width().max(60.0)) + .desired_width(width) .desired_rows(1) .layouter(&mut layouter) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 7edb13ef6a..a4024e513e 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -737,6 +737,29 @@ fn the_text_editor_keeps_the_boxs_alignment() { assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); } +/// A box narrower than the editor's least width (60 points on screen): right-aligned text stays +/// at the box's right edge, not the wider editor's, which lies past it. +#[test] +fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 60.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 50.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); + let pixels = h.render().unwrap(); + let inside = ink(&pixels, r); + let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); + assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); +} + fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } From eba7e47159621e9993dd26b983ba9c560e2464e9 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:48:26 +1100 Subject: [PATCH 10/82] M7: text_edit with aligns alone keeps the paragraph's rows Without new text, text_edit rewrote a paragraph from its text joined with spaces, so its rows were rewrapped into one and only the first entry of `aligns`, which counts rows, had any effect. With `aligns` given, the paragraph's own rows (its editor text) are kept, as in the app. --- crates/automation/src/lib.rs | 5 ++++- crates/automation/tests/it/automation.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8c5ae96bc3..c92ba9b8fb 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -696,7 +696,10 @@ impl Automation { }; let index = self.paragraph_index(id, page, k, &blocks)?; let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + // `aligns` counts rows, so without new text the paragraph keeps its rows (the + // editor text); otherwise its text joined with spaces is rewrapped. + let own = if a.get("aligns").is_some() { block.editor_text } else { block.text }; + let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(own); let mut style = pdfcraft_engine::BlockStyle { ranges: parse_style_ranges(a.get("ranges"))?, resource: a.opt_str("font_resource")?.map(str::to_owned), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 5b8931c6af..46f4bf391c 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8447,3 +8447,23 @@ fn added_text_lines_take_their_own_alignments() { assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); } + +/// `aligns` alone on an existing paragraph aligns its rows by index, as the app does: the rows +/// stay rows instead of being rewrapped into one, where only the first entry would count. +#[test] +fn text_edit_aligns_alone_aligns_the_paragraphs_rows() { + let dir = workdir("paragraph-row-aligns"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET"; + std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] })); + let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + let rect = |t: &str| -> Vec { + let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == t).unwrap_or_else(|| panic!("{t:?} is a row: {lines}")); + line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() + }; + let (first, second) = (rect("The quick brown fox jumps over the lazy dog"), rect("and keeps running.")); + assert!((first[0] - 72.0).abs() < 0.5, "the first row stays at the left: {first:?}"); + assert!((second[2] - first[2]).abs() < 1.0 && second[0] > 100.0, "the second row is right-aligned: {second:?} vs {first:?}"); +} From 7f0b451e2b4aaeb7bb31f9ec10670ac862a9d1f5 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:48:26 +1100 Subject: [PATCH 11/82] fix(ui): a narrow added-text box keeps the edge its lines share The editor of a box narrower than its 60-point minimum was anchored by the box's own alignment. A box whose lines are all right-aligned (or centred) on their own while the box stays Left wasn't anchored, so its text was placed against the wider editor's edge, past the box. The lines are now compared with each other and the editor keeps the edge they share. --- crates/ui-egui/src/content_ui.rs | 10 ++++++---- crates/ui-egui/tests/it/content.rs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 58f954a895..5e4f2975aa 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -565,10 +565,12 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // shortcut: lines aligned differently from each other in a box under 60 points keep the // editor's left edge; anchor each line on its own if such boxes turn out to matter. let width = r.width().max(60.0); - let uniform = (0..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == draft.style.align); - let anchor = match draft.style.align { - pdfcraft_engine::TextAlign::Center if uniform => 0.5, - pdfcraft_engine::TextAlign::Right if uniform => 1.0, + // The alignment every line shares, its own or the box's. + let first = draft.style.paragraph_align(0); + let shared = (1..pdfcraft_engine::paragraph_align::count(&draft.text)).all(|i| draft.style.paragraph_align(i) == first).then_some(first); + let anchor = match shared { + Some(pdfcraft_engine::TextAlign::Center) => 0.5, + Some(pdfcraft_engine::TextAlign::Right) => 1.0, _ => 0.0, }; let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index a4024e513e..0798abdff5 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -760,6 +760,29 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); } +/// The same when the line is right-aligned on its own while the box keeps Left: all its lines +/// share one alignment, so the editor keeps the box's right edge for them. +#[test] +fn the_text_editor_aligns_a_narrow_box_whose_lines_share_an_alignment() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 60.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), aligns: vec![Some(pdfcraft_engine::TextAlign::Right)], ..Default::default() }; + assert_eq!(text.align, pdfcraft_engine::TextAlign::Left); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 50.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + let pixels = h.render().unwrap(); + let inside = ink(&pixels, r); + let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); + assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); +} + fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") } From d162efacaa9722a3e214a8deeddeba00541231ac Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:57:46 +1100 Subject: [PATCH 12/82] test(ui): the Redo test checks Undo restores the exact paragraph text --- crates/ui-egui/tests/it/editing.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index b19f69cb46..2df634ef2f 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6143,13 +6143,14 @@ fn redo_after_undoing_typing_puts_the_typing_back() { h.run_steps(4); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), Some("Edit text")); open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); h.key_press_modifiers(Modifiers::COMMAND, Key::End); h.event(egui::Event::Text(" more".into())); h.run_steps(2); let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(3); - assert_ne!(h.state().views[0].line_editor.as_ref().unwrap().text, typed, "the typing is taken back"); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(3); let s = h.state(); From 489a76acbaf41319d9fbdc09f2038a404163bdd5 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:14:01 +1100 Subject: [PATCH 13/82] fix(ui): an unchanged added-text box closes without an edit Finishing a box trimmed its text before comparing it with the item, so an unchanged box whose text was saved with trailing spaces finished as an edit. Before an Undo or Redo step that edit was applied, clearing the document's Redo. A box matching its item is now no edit. --- crates/ui-egui/src/content_ui.rs | 8 ++++++++ crates/ui-egui/tests/it/content.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5e4f2975aa..ff987716d1 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -678,6 +678,14 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad /// A finished draft as an edit: new text is added (and selected once it exists), retyped text /// updated, and text emptied by retyping removed. Empty new text adds nothing. fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { + // Unchanged is no edit, even for text saved with trailing spaces the trim would drop. + if let Some(i) = d.index + && let Some((_, a)) = on_page(added, d.page).into_iter().find(|(k, _)| *k == i) + && let AddedContent::Text(old) = &a.content + && *old == (AddedText { text: d.text.clone(), rect: d.rect, ..d.style.clone() }) + { + return None; + } let text = d.text.trim_end().to_string(); let aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); let d = TextDraft { style: AddedText { aligns, ..d.style }, ..d }; diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0798abdff5..2b84582fea 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -991,3 +991,29 @@ fn redo_after_undoing_typing_in_added_text_puts_it_back() { let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits"); } + +/// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when +/// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear +/// that Redo first. +#[test] +fn redo_with_an_unchanged_box_open_redoes_the_document() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed ".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + let pdfcraft_engine::AddedContent::Text(saved) = added(&h)[0].content.clone() else { panic!() }; + assert_eq!(saved.text, "Reviewed ", "the trailing space is kept"); + let bold = pdfcraft_engine::AddedText { bold: true, ..saved }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); + assert!(h.state_mut().execute("edit.undo")); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + h.run_steps(4); + let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; + assert!(now.bold, "the waiting Redo was redone"); + assert!(h.state().views[0].content.draft.is_none(), "the box closed with the step"); +} From 56b5d4f52e0748962035323ec3e51cd3a6300241 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:21 +1100 Subject: [PATCH 14/82] test(ui): the narrow-box tests check the text reaches the box's right edge --- crates/ui-egui/tests/it/content.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 2b84582fea..f572d7feb2 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -756,8 +756,12 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); let pixels = h.render().unwrap(); let inside = ink(&pixels, r); + let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); - assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); + assert!( + inside > 20 && edge > 0 && past == 0, + "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" + ); } /// The same when the line is right-aligned on its own while the box keeps Left: all its lines @@ -779,8 +783,12 @@ fn the_text_editor_aligns_a_narrow_box_whose_lines_share_an_alignment() { let r = item_on_screen(&h, rect); let pixels = h.render().unwrap(); let inside = ink(&pixels, r); + let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); - assert!(inside > 20 && past == 0, "the text is drawn in its box ({inside} dark pixels in it, {past} past its right edge)"); + assert!( + inside > 20 && edge > 0 && past == 0, + "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" + ); } fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { From 7e96230a12b8e198d4e5e84f29dbd19829dee838 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:30 +1100 Subject: [PATCH 15/82] M7: parity notes say a justified row in a mixed-style region isn't stretched --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f0deaf5b16..9eb608eedd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1963,7 +1963,7 @@ status = "shipped" commands = ["edit.edit_text", "edit.text"] tools = ["text_edit", "content_update"] evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/edit/src/tests.rs::a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/edit/src/tests.rs::one_row_of_a_paragraph_takes_its_own_alignment", "crates/edit/src/tests.rs::one_row_of_a_styled_region_takes_its_own_alignment", "crates/edit/src/tests.rs::added_text_paragraphs_keep_their_own_alignments", "crates/ui-egui/tests/it/editing.rs::aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back", "crates/ui-egui/tests/it/content.rs::aligning_a_selected_line_of_added_text_aligns_only_that_line", "crates/automation/tests/it/automation.rs::added_text_lines_take_their_own_alignments"] -notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581). With text selected, Format text aligns only the lines (rows between line breaks) the selection touches; with only the caret, the whole paragraph or box (aligns in text_edit, page_add_text and content_update). Added text keeps each line's alignment in its /PCAdded parameters, and a justified line's last row is not stretched. Missing: an edited paragraph's per-row alignment is not recorded in the page, so after it is closed and reopened a later rewrite of it can put that row back to the paragraph's alignment (as whole-paragraph justify is lost, #2508/#2515); aligning a row can regroup the paragraph, and the editor then shows only the part it groups first; the added-text editor stretches justified lines with egui's own spacing, close to but not exactly as written" +notes = "left, centre, right and justify (word spacing per line, last line left) for existing paragraphs and added text. A centred paragraph is found either from its ragged rows or, for a one row title, from its centre sitting on the page's own centre away from the edge the rest of the text uses, so growing or wrapping it keeps that centre (#2581). With text selected, Format text aligns only the lines (rows between line breaks) the selection touches; with only the caret, the whole paragraph or box (aligns in text_edit, page_add_text and content_update). Added text keeps each line's alignment in its /PCAdded parameters, and a justified line's last row is not stretched. Missing: an edited paragraph's per-row alignment is not recorded in the page, so after it is closed and reopened a later rewrite of it can put that row back to the paragraph's alignment (as whole-paragraph justify is lost, #2508/#2515); aligning a row can regroup the paragraph, and the editor then shows only the part it groups first; a row justified on its own in a region of mixed styles is left-aligned, as the styled-region rewrite doesn't justify (a whole paragraph there isn't justified either); the added-text editor stretches justified lines with egui's own spacing, close to but not exactly as written" [[feature]] id = "edit.line-paragraph-spacing" From ea6420cde51ea9429506f8ec3c7e91ec2a84283e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:24 +1100 Subject: [PATCH 16/82] M7: a lone row aligns across the column under it, not the page's busiest alignment_frame found the column by the left edge most of the page's other rows share. On a page of two columns, a title over the column with fewer rows took the other column's edge, failed to fit in it, and was aligned in its own box. Only rows reaching across the title now count. Tests: the two-column case; a two-colour title (the styled-region path) is framed too; a justified paragraph sent with its rows split by line breaks, as the editor sends it, still fills every row but the last. --- crates/edit/src/tests.rs | 65 +++++++++++++++++++++++++++++++++ crates/edit/src/text_regions.rs | 19 +++++----- 2 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ecac4c782c..a20dba68af 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6742,3 +6742,68 @@ fn a_lone_row_aligns_across_the_column_it_sits_in() { align(&mut doc, added::Align::Right); assert!((title(&doc)[2] - column[1]).abs() < 1.0, "right: on the column's right edge, {:?} vs {column:?}", title(&doc)); } + +/// An edited paragraph's text has a line break between its source rows (`editor_text`), as the +/// paragraph editor sends it. Those are the rows of one paragraph, not paragraph ends: justified +/// in a font of its own (the single-style rewrite), every row but the last still fills the width. +#[test] +fn a_justified_paragraph_from_the_editor_fills_its_rows() { + let mut doc = + text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + assert_eq!(block.editor_text.matches('\n').count(), 2); + let style = text::BlockStyle { align: Some(added::Align::Justify), family: Some((Family::Helvetica, true, false)), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let row = |t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect; + let right = row("One")[2]; + assert!((row("eight")[2] - right).abs() < 1.0, "the second row is stretched: {:?} vs {right}", row("eight")); + assert!(row("end")[2] < right - 20.0, "the last row isn't"); +} + +/// The same on a page of two columns, where the other column has more rows: a title over the +/// first column is aligned across the first column, not its own box. +#[test] +fn a_lone_row_aligns_across_its_own_column_of_two() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 120 700 Tm (Title) Tj ET \ + BT /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj T* (Four.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { aligns: vec![Some(added::Align::Left)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + assert!((title[0] - column[0]).abs() < 0.5, "left: on the first column's edge, {title:?} vs {column:?}"); +} + +/// A one-row title in two colours is rewritten by the styled-region path, which is given the +/// same column frame: centred twice, it stays centred in the column instead of moving right. +#[test] +fn a_lone_row_of_two_styles_aligns_across_its_column() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 250 700 Tm (Big ) Tj 1 0 0 rg (Title) Tj ET \ + BT 0 g /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Big Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let block = text::text_blocks(doc, 0).unwrap().into_iter().find(|b| b.text == "Big Title").unwrap(); + block.lines.iter().map(|i| lines[*i].rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap() + }; + let centre = |doc: &mut Document| { + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == "Big Title").unwrap(); + let style = text::BlockStyle { aligns: vec![Some(added::Align::Center)], ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + centre(&mut doc); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + centre(&mut doc); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); +} diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index d9465cdffd..c8758d4422 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -30,11 +30,12 @@ fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { /// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row /// that does not share it is not merely an indented or full-width left-aligned line, so its own -/// centre is the only remaining explanation for where it sits. -fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { +/// centre is the only remaining explanation for where it sits. With `over`, only rows reaching +/// across that span count: the edge of the column under it, however many rows another column has. +fn neighbour_left(lines: &[TextLine], block: &TextBlock, over: Option<[f64; 2]>) -> Option { let mut edges: HashMap = HashMap::new(); for (i, line) in lines.iter().enumerate() { - if block.lines.contains(&i) { + if block.lines.contains(&i) || over.is_some_and(|[left, right]| line.rect[2] <= left || line.rect[0] >= right) { continue; } // Quarter-point buckets: equal edges found through different runs still agree. @@ -45,8 +46,8 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { } /// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of -/// text it sits in (the left edge the rest of the page's text uses, to the furthest right those -/// rows reach) when that column holds it, otherwise its own box. A lone row's own box is no frame +/// text it sits in (the left edge the text under or over it uses, to the furthest right those rows +/// reach) when that column holds it, otherwise its own box. A lone row's own box is no frame /// to align in: left-aligning a title centred on the page left it where it was, and since a single /// row grows to the page's margin, centring it again moved it right each time. `None` when no /// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a @@ -58,7 +59,7 @@ pub(super) fn alignment_frame(lines: &[TextLine], block: &TextBlock, style: &Blo } let own = [block.rect[0], block.rect[2]]; let tolerance = block.size.max(1.0) * 0.5; - let Some(left) = neighbour_left(lines, block) else { return Some(own) }; + let Some(left) = neighbour_left(lines, block, Some(own)) else { return Some(own) }; let right = lines .iter() .enumerate() @@ -85,9 +86,9 @@ pub(super) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64 return left_span > size * 0.5 && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size)); } - lefts - .first() - .is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5)) + lefts.first().is_some_and(|left| { + (centre - page_centre).abs() < size && neighbour_left(lines, block, None).is_none_or(|edge| (*left - edge).abs() > size * 0.5) + }) } pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { From 98a5dde39852ba9e071d9cb97ebcf13827f3896c Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:34:24 +1100 Subject: [PATCH 17/82] fix(ui): formatting keeps per-row alignments, the selection, and Redo - A row aligned on its own lost its alignment when the paragraph was then given a colour, size or font: the rewrite left the rows' alignments out once they had been applied. They now go with every rewrite. - egui drops a box's selection while the box is unfocused, so after one alignment or format a second choice went to the whole paragraph or box. The selection is given back when formatting returns the focus. - Redo from the menu or toolbar applied the box's typing first, a new edit that dropped the document's Redo. It now redoes in the box, as the key does. --- crates/ui-egui/src/content_ui.rs | 3 ++ crates/ui-egui/src/edit_text_ui.rs | 33 +++++++++++-- crates/ui-egui/src/editing.rs | 36 ++++++++++++++- crates/ui-egui/tests/it/content.rs | 12 ++++- crates/ui-egui/tests/it/editing.rs | 74 ++++++++++++++++++++++++++++++ 5 files changed, 150 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index ff987716d1..d30a814d43 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -636,6 +636,9 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } if t.focus { resp.request_focus(); + if let Some(range) = &t.selection { + crate::edit_text_ui::reselect(ui.ctx(), input, &t.text, range); + } t.focus = false; } let buttons = egui::Frame::NONE diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f9074af30f..53426d8757 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -281,6 +281,19 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut *from = Some(text.to_owned()); } +/// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection +/// only while it has the focus, so a click on Format text drops it; given back as formatting +/// returns the focus, it stays shown, and a second choice reaches the same text. +pub(crate) fn reselect(ctx: &egui::Context, id: egui::Id, text: &str, range: &std::ops::Range) { + let chars = |at: usize| text.get(..at).map(|s| s.chars().count()); + let (Some(start), Some(end)) = (chars(range.start), chars(range.end)) else { return }; + if start < end { + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(start), egui::text::CCursor::new(end)))); + egui::TextEdit::store_state(ctx, id, state); + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Extras { pub underline: bool, @@ -359,9 +372,6 @@ impl LineEditor { // The whole paragraph's alignment when it was picked, or when the rows' own were // dropped for it (the rows are where those put them, so leaving it out keeps them there). align: (l.align != o.align || l.aligns.is_empty() && !o.aligns.is_empty()).then_some(l.align), - // Rows aligned on their own, sent with every rewrite of the paragraph while it is open: - // nothing in the page records them, so a rewrite without them would undo them. - aligns: if l.aligns != o.aligns || self.text != self.original { l.aligns.clone() } else { Vec::new() }, underline: (self.extras.underline != self.extras0.underline).then_some(self.extras.underline), line_spacing: (self.extras.line_spacing != self.extras0.line_spacing && self.extras.line_spacing > 0.0) .then_some(self.extras.line_spacing), @@ -369,6 +379,12 @@ impl LineEditor { scale: (self.extras.scale != self.extras0.scale).then_some(self.extras.scale), ..Default::default() }; + // Rows aligned on their own, sent with every rewrite of the paragraph while it is open + // (a colour or size change too): nothing in the page records them, so a rewrite without + // them would undo them. Alone, they are sent only when they changed. + if l.aligns != o.aligns || self.text != self.original || style != pdfcraft_engine::BlockStyle::default() { + style.aligns = l.aligns.clone(); + } if let Some(range) = &self.selection && (style.family.is_some() || style.resource.is_some() @@ -392,9 +408,9 @@ impl LineEditor { style } - /// After applying formatting: the paragraph now has it. + /// After applying formatting: the paragraph now has it. The selection stays, as the box still + /// shows it, so a second alignment or format picked for it applies to the same rows. pub fn applied(&mut self) { - self.selection = None; self.font_resource = None; self.family_chosen = false; self.look0 = self.look.clone(); @@ -409,6 +425,10 @@ impl LineEditor { self.multiline = block.rows.len() > 1; self.source = Some((block.clone(), lines)); let before = std::mem::replace(&mut self.text, block.editor_text.clone()); + if self.text != before { + // A byte range of the old text: it may name other characters, or none, in the new. + self.selection = None; + } self.follow_rows(&before); self.original = self.text.clone(); } @@ -1755,6 +1775,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } if ed.focus { r.request_focus(); + if let Some(range) = &ed.selection { + reselect(ui.ctx(), input, &ed.text, range); + } ed.focus = false; } let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2b31091084..b7109b9c50 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -222,11 +222,33 @@ impl PdfCraftApp { pub fn redo(&mut self) { if self.combine_showing() { self.combine_history_step(false); - } else { + } else if !self.redo_in_page_box() { self.history_step(false); } } + /// Redo from the menu or toolbar while a box on the page has typing of its own: the step is + /// the box's, as the key's is. What Undo took back in the box comes back; typing the page + /// doesn't have yet stays as it is, since applying it first is a new edit, which drops the + /// document's Redo. True when the box took the step. + fn redo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + if let Some(ed) = view.line_editor.as_mut() { + let Some(text) = redo_typing(&ctx, crate::edit_text_ui::input_id(), &ed.text) else { return ed.has_unsaved_text() }; + let before = std::mem::replace(&mut ed.text, text); + ed.follow_rows(&before); + return true; + } + if let Some(d) = view.content.draft.as_mut() { + let Some(text) = redo_typing(&ctx, crate::content_ui::input_id(), &d.text) else { return d.has_unapplied_typing() }; + d.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); + d.text = text; + return true; + } + false + } + fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; if !self.close_text_editors(i, id) { @@ -1041,6 +1063,18 @@ impl PdfCraftApp { /// The single page a content edit changes (`None` for other edits). Like comment edits, /// these leave every other page's bytes, geometry and text alone, so their rasters stay valid. +/// What the page text box `id`, showing `text`, gets back from its own Redo history, if anything; +/// its caret goes where the Redo puts it. +fn redo_typing(ctx: &egui::Context, id: egui::Id, text: &str) -> Option { + let mut state = egui::TextEdit::load_state(ctx, id)?; + let mut undoer = state.undoer(); + let (range, redone) = undoer.redo(&(state.cursor.char_range()?, text.to_owned()))?.clone(); + state.set_undoer(undoer); + state.cursor.set_char_range(Some(range)); + egui::TextEdit::store_state(ctx, id, state); + Some(redone) +} + fn content_page(edit: &Edit) -> Option { match edit { Edit::MoveObjects { page, .. } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index f572d7feb2..1fd887bf5e 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -939,7 +939,7 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { } /// Bug A for added text: with one line of the box selected, an alignment applies to that line -/// only, the box stays open, and Undo puts it back and closes the box. +/// only, as does a second one, the box stays open, and Undo puts it back and closes the box. #[test] fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { let mut h = harness(); @@ -964,10 +964,18 @@ fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { }; assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)])); assert!(h.state().views[0].content.draft.is_some(), "the box stays open"); + // A second choice, the line still selected, is for that line again. + let button = h.get_by_label("Alignment").rect().center(); + click(&mut h, button); + let item = h.get_by_label("Centre").rect().center(); + click(&mut h, item); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Center)])); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); h.run_steps(4); - assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![]), "Undo puts it back"); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)]), "Undo puts it back"); assert!(h.state().views[0].content.draft.is_none(), "and closes the box"); + assert!(h.state_mut().execute("edit.undo")); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![])); } /// The same in an added text box: Redo after Undo puts the typing back in the box, and the diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 2df634ef2f..55a77452fc 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6159,3 +6159,77 @@ fn redo_after_undoing_typing_puts_the_typing_back() { let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); } + +/// A row aligned on its own keeps its alignment when the whole paragraph is then made bold: the +/// rewrite for the bold carries the rows' alignments too, as nothing in the page records them. +#[test] +fn a_row_aligned_on_its_own_keeps_it_through_more_formatting() { + let mut h = three_row_paragraph(); + let wide = row_rect(&h, "three"); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_pointer(&mut h, "Align right"); + // Nothing selected: the bold is for the whole paragraph. + h.key_press(Key::End); + h.run_steps(2); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None); + h.get_by_label("Bold").click(); + h.run_steps(4); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].base_font, "Helvetica-Bold"); + let last = row_rect(&h, "nine"); + assert!( + (last[2] - row_rect(&h, "three")[2].max(wide[2])).abs() < 1.0 && last[0] > wide[0] + 20.0, + "the last row is still right-aligned: {last:?}" + ); +} + +/// A second alignment picked while the same row is still selected applies to that row again, not +/// the whole paragraph. +#[test] +fn a_second_alignment_for_a_selected_row_stays_on_that_row() { + let mut h = three_row_paragraph(); + let first = row_rect(&h, "One"); + let wide = row_rect(&h, "three"); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_pointer(&mut h, "Align right"); + align_by_pointer(&mut h, "Centre"); + assert!((row_rect(&h, "One")[0] - first[0]).abs() < 0.5, "the first row stays put: {:?}", row_rect(&h, "One")); + let last = row_rect(&h, "nine"); + let (mid, centre) = ((last[0] + last[2]) / 2.0, (wide[0] + wide[2]) / 2.0); + assert!((mid - centre).abs() < 1.0, "the last row is centred: {last:?} in {wide:?}"); +} + +/// Redo from the menu (not the key) after Undo in the box took the typing back: the typing comes +/// back in the box, and the document's own Redo (a formatting change) still waits. The menu went +/// to the document, which applied the box first: a new edit, which dropped that Redo. +#[test] +fn redo_from_the_menu_puts_the_typing_back_in_the_box() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + // Undo the bold (closing the editor), so the document has it to redo. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("still typing"); + assert_eq!(ed.text, typed, "Redo puts the typing back"); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); +} From caf7bdd8e1b251b87bb0cd9d6e8f536ff6b28a8d Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:24:58 +1100 Subject: [PATCH 18/82] fix(ui): typing in one tab's text box never reaches another tab's The paragraph box, and the added-text box, each keep one Undo history under one egui id, shared by every tab. A box cleared it when it opened on new text, but a box in another tab on the same text (two copies of a document) kept it: typing taken back with Undo in one tab came back with Redo in the other, from the key or from the menu. The document that last used the history is now kept beside it, and the history is cleared whenever another document's box takes it over. Tests: the paragraph and added-text cases, each with two copies of a document open; the paragraph case tries the menu's Redo too. --- crates/ui-egui/src/content_ui.rs | 3 +- crates/ui-egui/src/edit_text_ui.rs | 19 ++++++++----- crates/ui-egui/tests/it/content.rs | 37 ++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 45 ++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index d30a814d43..4578d0d9d8 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -530,6 +530,7 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { /// committed (a click away, or Done), and whether Done finished adding text. Discard or Escape /// throws the draft away. pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option, bool) { + let doc_id = view.id; let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) }; let Some(xf) = view.page_xform(page) else { return (None, false) }; let zoom = xf.rect.width() / xf.pw.max(1.0); @@ -611,7 +612,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| aligned_galley(ui, text.as_str(), &style, &line_font, color, wrap); let before = t.text.clone(); let input = input_id(); - crate::edit_text_ui::forget_typing_before(ui.ctx(), input, &mut t.typing_from, &t.style.text); + crate::edit_text_ui::forget_typing_before(ui.ctx(), input, doc_id, &mut t.typing_from, &t.style.text); ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let output = egui::TextEdit::multiline(&mut t.text) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 53426d8757..12adb622fb 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -266,18 +266,22 @@ pub(crate) fn input_id() -> egui::Id { } /// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown -/// (they share the id). Once the page has `text` (the box opened on it, or formatting applied it), -/// that history is cleared: Undo in the box takes back only what was typed since, and then reaches -/// the document. Kept, it put back text from another paragraph, or the whole paragraph as it was -/// before a format change, into a box showing the page as it is now. -pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, from: &mut Option, text: &str) { - if from.as_deref() == Some(text) { +/// in every tab (they share the id). Once the page has `text` (the box opened on it, or formatting +/// applied it), that history is cleared: Undo in the box takes back only what was typed since, and +/// then reaches the document. Kept, it put back text from another paragraph, or the whole paragraph +/// as it was before a format change, into a box showing the page as it is now. `from` is the box's +/// own record of that; which document `doc` last used the history is kept beside it, since a box +/// in another tab on the same text would otherwise take that document's typing with its Redo. +pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { + let owner = id.with("typing-doc"); + if from.as_deref() == Some(text) && ctx.data(|d| d.get_temp::(owner)) == Some(doc) { return; } if let Some(mut state) = egui::TextEdit::load_state(ctx, id) { state.clear_undoer(); egui::TextEdit::store_state(ctx, id, state); } + ctx.data_mut(|d| d.insert_temp(owner, doc)); *from = Some(text.to_owned()); } @@ -1624,6 +1628,7 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { + let view_id = view.id; // Clicks outside the document (the Format text panel) keep the paragraph open. // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a // pen lifted away). @@ -1743,7 +1748,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - }; let before = ed.text.clone(); let input = input_id(); - forget_typing_before(ui.ctx(), input, &mut ed.typing_from, &ed.original); + forget_typing_before(ui.ctx(), input, view_id, &mut ed.typing_from, &ed.original); let output = egui::TextEdit::multiline(&mut ed.text) .id(input) .font(font.clone()) diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 1fd887bf5e..18e3144f83 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1033,3 +1033,40 @@ fn redo_with_an_unchanged_box_open_redoes_the_document() { assert!(now.bold, "the waiting Redo was redone"); assert!(h.state().views[0].content.draft.is_none(), "the box closed with the step"); } + +/// Two copies of a document, the same added text open in each: the boxes share one Undo history, +/// so typing taken back with Undo in one tab came back with Redo in the other. +#[test] +fn typing_in_one_tab_never_reaches_another_tabs_added_text() { + let mut h = harness(); + h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(4); + let text = pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 210.0], text: "Reviewed".into(), ..Default::default() }; + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); + assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + }; + for tab in [0, 1] { + h.state_mut().active = Some(tab); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + } + open_in(&mut h, 1); + open_in(&mut h, 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" secret".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "taken back"); + h.state_mut().active = Some(1); + h.run_steps(3); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[1].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "the other tab's typing stays out"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 55a77452fc..f68f997a16 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6233,3 +6233,48 @@ fn redo_from_the_menu_puts_the_typing_back_in_the_box() { let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); } + +/// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text +/// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it. +/// Typing in one tab, taken back with Undo, then came back with Redo (the key or the menu) in the +/// other tab. +#[test] +fn typing_in_one_tab_never_reaches_another_tabs_paragraph() { + let mut h = harness(1, |app| { + app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens"); + }); + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height())); + assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing"); + }; + open_in(&mut h, 1); + let original = h.state().views[1].line_editor.as_ref().unwrap().text.clone(); + open_in(&mut h, 0); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the same paragraph"); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" secret".into())); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "taken back"); + // The copy, its paragraph still open: Redo there has nothing of its own to put back. + h.state_mut().active = Some(1); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(3); + let text = h.state().views[1].line_editor.as_ref().map(|ed| ed.text.clone()); + assert!(!text.as_deref().unwrap_or_default().contains("secret"), "the other tab's typing came in: {text:?}"); + // Nor through what the menu's Redo runs, which reads the box's history itself (the menu item + // is off here, as the copy's document has nothing to redo). + h.state_mut().redo(); + h.run_steps(3); + let s = h.state(); + let text = s.views[1].line_editor.as_ref().map(|ed| ed.text.clone()); + assert!(!text.as_deref().unwrap_or_default().contains("secret"), "the other tab's typing came in from the menu: {text:?}"); + assert_eq!(s.session.get(s.views[1].id).unwrap().text_blocks(0)[0].text, s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].text); +} From dce32fb2ab6cc6d2acb55cfecd99fa6be8e4a335 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:17:48 +1100 Subject: [PATCH 19/82] fix(ui): Redo from the menu with typing in a page box stays in the box Redo from the menu or toolbar closed the box first, which applied its typing: a new edit that dropped the document's waiting Redo, and the Redo itself then failed. The step was gone. Redo now goes to the box as the key does: what Undo took back there comes back, and typing the page doesn't have yet stays as it is, with the document's Redo waiting. A new text box the document refused was put back without the request to select the item once it exists, which stayed behind. It is put back with the box. Carried from #4179 (632ba10e). This branch already routed the menu's Redo to the box; the test from #4179 covers what its own redo_from_the_menu_puts_the_typing_back_in_the_box did, so that one goes. --- crates/ui-egui/src/editing.rs | 45 ++++++++++++++++++++++++++++-- crates/ui-egui/tests/it/content.rs | 33 ++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 26 +++++++++++------ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index b7109b9c50..7b65592c1d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -296,12 +296,13 @@ impl PdfCraftApp { } let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); let Some(view) = self.views.get_mut(i) else { return true }; - let draft = view.content.draft.clone(); + // Finishing new text also asks for it to be selected once it exists: put that back too. + let (draft, select_added) = (view.content.draft.clone(), view.content.select_added); if let Some(edit) = view.content.finish_draft(&added) && !self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { - view.content.draft = draft; + (view.content.draft, view.content.select_added) = (draft, select_added); } return false; } @@ -1583,6 +1584,46 @@ mod tests { out } + /// `one_page_pdf`, opening without a password, whose permissions refuse changes. + fn no_changes_pdf() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(one_page_pdf())).unwrap(); + let encryption = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }; + doc.set_encryption(&encryption).unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() + } + + /// Undo with a new text box open on a document that refuses changes: the box is put back as + /// it was. Finishing it had asked for the new item to be selected, and that request stayed + /// behind once the add was refused. + #[test] + fn a_refused_text_box_is_put_back_whole() { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, no_changes_pdf()).unwrap(); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed".into(), + style, + focus: false, + selection: None, + typing_from: None, + }); + app.undo(); + let content = &app.views[0].content; + assert_eq!(content.draft.as_ref().map(|d| d.text.as_str()), Some("Typed"), "the refused box stays open"); + assert_eq!(content.select_added, None, "nothing was added to select"); + } + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 18e3144f83..8fefd84ecd 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1008,6 +1008,39 @@ fn redo_after_undoing_typing_in_added_text_puts_it_back() { assert_eq!(doc.can_redo(), Some("Edit content"), "the document's Redo still waits"); } +/// Redo from the menu with retyping the page doesn't have yet in an added text box: the typing +/// stays in the box and the document's Redo waits (closing the box first wrote the typing, which +/// dropped that Redo). After Undo in the box, Redo from the menu puts the typing back there. +#[test] +fn redo_from_the_menu_with_retyping_in_the_box_keeps_the_documents_redo() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + let bold = pdfcraft_engine::AddedText { bold: true, ..text }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); + assert!(h.state_mut().execute("edit.undo")); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "the typing stays in the box"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo from the menu puts it back"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index f68f997a16..ae33cfd23a 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6202,11 +6202,12 @@ fn a_second_alignment_for_a_selected_row_stays_on_that_row() { assert!((mid - centre).abs() < 1.0, "the last row is centred: {last:?} in {wide:?}"); } -/// Redo from the menu (not the key) after Undo in the box took the typing back: the typing comes -/// back in the box, and the document's own Redo (a formatting change) still waits. The menu went -/// to the document, which applied the box first: a new edit, which dropped that Redo. +/// Redo from the menu or toolbar with typing in the box the page doesn't have yet. Closing the +/// box first applied the typing, a new edit that dropped the document's waiting Redo, and the +/// Redo itself then failed: the step was gone. The typing now stays in the box and the Redo +/// waits; once Undo in the box takes the typing back, Redo from the menu puts it back there. #[test] -fn redo_from_the_menu_puts_the_typing_back_in_the_box() { +fn redo_from_the_menu_with_typing_in_the_box_keeps_the_documents_redo() { let mut h = harness(1, |_| {}); assert!(h.state_mut().execute("edit.edit_text")); h.run_steps(2); @@ -6222,16 +6223,23 @@ fn redo_from_the_menu_puts_the_typing_back_in_the_box() { h.event(egui::Event::Text(" more".into())); h.run_steps(2); let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + { + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("the box stays open"); + assert_eq!(ed.text, typed, "the typing stays in the box, unapplied"); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); + } h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(3); - assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "Undo takes the typing back in the box"); assert!(h.state_mut().execute("edit.redo")); h.run_steps(3); let s = h.state(); - let ed = s.views[0].line_editor.as_ref().expect("still typing"); - assert_eq!(ed.text, typed, "Redo puts the typing back"); - let doc = s.session.get(s.views[0].id).unwrap(); - assert_eq!((doc.can_redo(), doc.text_blocks(0)[0].base_font.as_str()), (Some("Edit text"), "Helvetica"), "the document's Redo still waits"); + assert_eq!(s.views[0].line_editor.as_ref().expect("still open").text, typed, "Redo from the menu puts the typing back"); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); } /// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text From f5e4b8e5709856d3dc5b6ff8e0f716eab7f8fd6e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:23:16 +1100 Subject: [PATCH 20/82] fix(ui): menu Redo is enabled for the open text box's own Redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit ▸ Redo and the toolbar button followed only the document's Redo. After Undo in a paragraph or added text box took the typing back, with no document step to redo, they stayed disabled and only the key could put the typing back. Redo is now also enabled when the box open on the page has a Redo of its own: the step redo_in_page_box takes. It doesn't ask for the box's focus, which the menu has taken by then. --- crates/ui-egui/src/commands.rs | 4 +++- crates/ui-egui/src/editing.rs | 21 +++++++++++++++++++-- crates/ui-egui/tests/it/content.rs | 25 +++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 23 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 2ac0f82022..25cb73beba 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -84,7 +84,9 @@ impl PdfCraftApp { if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } - commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + // Redo also takes back what Undo took in a box open on the page, with no document step. + (commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + || spec.needs == commands::Needs::Redo && self.open_box_can_redo()) && (spec.id != "edit.copy" || self .active diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6040b5c76f..cba20e76bc 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -333,8 +333,19 @@ impl PdfCraftApp { } else { return false; }; - egui::TextEdit::load_state(ctx, id) - .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.clone())))) + has_typing_to_redo(ctx, id, text) + } + + /// Whether the box open on the page has a Redo of its own, the step + /// [`Self::redo_in_page_box`] takes for Redo from the menu or toolbar: focus has moved to the + /// menu by then, so unlike [`Self::page_box_can_redo`] this doesn't ask for it. + pub(crate) fn open_box_can_redo(&self) -> bool { + let Some(ctx) = &self.ctx else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + if let Some(ed) = &view.line_editor { + return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text); + } + view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text)) } /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't @@ -1343,6 +1354,12 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } } +/// Whether the page text box `id`, showing `text`, has typing its own Redo would put back: the +/// test egui makes when it handles the key. +fn has_typing_to_redo(ctx: &egui::Context, id: egui::Id, text: &str) -> bool { + egui::TextEdit::load_state(ctx, id) + .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.to_owned())))) +} #[cfg(test)] mod tests { use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index f8e93ecd28..592981acb7 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1038,6 +1038,31 @@ fn redo_from_the_menu_with_retyping_in_the_box_keeps_the_documents_redo() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); } +/// Redo from the menu after Undo in an added text box, with nothing for the document to redo: the +/// menu item follows the box's own Redo too, and puts the typing back. +#[test] +fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo"); + assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step"); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 671d3d12b5..cae884622f 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6478,6 +6478,29 @@ fn redo_from_the_menu_with_typing_in_the_box_keeps_the_documents_redo() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); } +/// Redo from the menu after Undo in the box took the typing back, with nothing for the document +/// to redo: the menu item follows the box's own Redo too, and puts the typing back. It was +/// disabled, as it followed only the document's Redo, and only the key could do it. +#[test] +fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + let typed = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo"); + assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step"); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back"); +} + /// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text /// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it. /// Typing in one tab, taken back with Undo, then came back with Redo (the key or the menu) in the From b7f7b27f8f3f55157ae8cbd540f5131d05e5428c Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 08:36:17 +1100 Subject: [PATCH 21/82] fix(ui): menu Undo takes back typing in the open text box first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit ▸ Undo with typing in a paragraph or added text box that the page doesn't have yet applied the typing (a new edit, which dropped the document's waiting Redo), closed the box and undid the typing all at once; the key takes it back a step at a time in the box. Menu Undo now goes to the box first, as Redo does, and is enabled while the box holds such typing. Undo and Redo share typing_step for the box's own history. --- crates/ui-egui/src/commands.rs | 4 ++- crates/ui-egui/src/editing.rs | 52 +++++++++++++++++++++--------- crates/ui-egui/tests/it/content.rs | 26 +++++++++++++++ crates/ui-egui/tests/it/editing.rs | 29 +++++++++++++++++ 4 files changed, 95 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index ec49944529..d6eb6bbb0a 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -84,8 +84,10 @@ impl PdfCraftApp { if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } - // Redo also takes back what Undo took in a box open on the page, with no document step. + // Undo also takes back typing in a box open on the page, and Redo what Undo took there, + // with no document step. (commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + || spec.needs == commands::Needs::Undo && self.page_typing_unapplied() || spec.needs == commands::Needs::Redo && self.open_box_can_redo()) && (spec.id != "edit.copy" || self diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 55404a8dc9..6dce0631da 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -219,11 +219,31 @@ impl PdfCraftApp { } if self.combine_showing() { self.combine_history_step(true); - } else { + } else if !self.undo_in_page_box() { self.history_step(true); } } + /// Undo from the menu or toolbar while a box on the page holds typing the page doesn't have + /// yet: the box takes the step, as it does for the key, one step of its typing at a time. + /// Applying the typing first was a new edit, which dropped the document's Redo, and then the + /// typing was taken back all at once. True when the box took the step. + fn undo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; + ed.text = text; + return true; + } + if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; + d.text = text; + return true; + } + false + } + pub fn redo(&mut self) { if self.combine_showing() { self.combine_history_step(false); @@ -240,13 +260,13 @@ impl PdfCraftApp { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; if let Some(ed) = view.line_editor.as_mut() { - let Some(text) = redo_typing(&ctx, crate::edit_text_ui::input_id(), &ed.text) else { return ed.has_unsaved_text() }; + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); ed.follow_rows(&before); return true; } if let Some(d) = view.content.draft.as_mut() { - let Some(text) = redo_typing(&ctx, crate::content_ui::input_id(), &d.text) else { return d.has_unapplied_typing() }; + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; d.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); d.text = text; return true; @@ -1143,18 +1163,6 @@ impl PdfCraftApp { /// The single page a content edit changes (`None` for other edits). Like comment edits, /// these leave every other page's bytes, geometry and text alone, so their rasters stay valid. -/// What the page text box `id`, showing `text`, gets back from its own Redo history, if anything; -/// its caret goes where the Redo puts it. -fn redo_typing(ctx: &egui::Context, id: egui::Id, text: &str) -> Option { - let mut state = egui::TextEdit::load_state(ctx, id)?; - let mut undoer = state.undoer(); - let (range, redone) = undoer.redo(&(state.cursor.char_range()?, text.to_owned()))?.clone(); - state.set_undoer(undoer); - state.cursor.set_char_range(Some(range)); - egui::TextEdit::store_state(ctx, id, state); - Some(redone) -} - fn content_page(edit: &Edit) -> Option { match edit { Edit::MoveObjects { page, .. } @@ -1366,6 +1374,20 @@ fn has_typing_to_redo(ctx: &egui::Context, id: egui::Id, text: &str) -> bool { egui::TextEdit::load_state(ctx, id) .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.to_owned())))) } + +/// The box's own Undo (or Redo), as the key does it: the box's text one step back (or forward) in +/// its typing, with the box's history and cursor moved along. None when there is no such step. +fn typing_step(ctx: &egui::Context, id: egui::Id, text: &str, undo: bool) -> Option { + let mut state = egui::TextEdit::load_state(ctx, id)?; + let mut undoer = state.undoer(); + let current = (state.cursor.char_range()?, text.to_owned()); + let (range, stepped) = if undo { undoer.undo(¤t) } else { undoer.redo(¤t) }?.clone(); + state.set_undoer(undoer); + state.cursor.set_char_range(Some(range)); + egui::TextEdit::store_state(ctx, id, state); + Some(stepped) +} + #[cfg(test)] mod tests { use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 592981acb7..327e3f45eb 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1038,6 +1038,32 @@ fn redo_from_the_menu_with_retyping_in_the_box_keeps_the_documents_redo() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); } +/// Undo from the menu with retyping the page doesn't have yet in an added text box: the box takes +/// it back and stays open, and the document's waiting Redo stays. +#[test] +fn undo_from_the_menu_takes_back_retyping_in_the_added_text_box() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + let bold = pdfcraft_engine::AddedText { bold: true, ..text }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_engine::AddedContent::Text(bold) })); + assert!(h.state_mut().execute("edit.undo")); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit content"), "the document's Redo still waits"); +} + /// Redo from the menu after Undo in an added text box, with nothing for the document to redo: the /// menu item follows the box's own Redo too, and puts the typing back. #[test] diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 2c68f21cc7..ceb4c36007 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6688,6 +6688,35 @@ fn redo_from_the_menu_with_typing_in_the_box_keeps_the_documents_redo() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); } +/// Undo from the menu or toolbar with typing in the box the page doesn't have yet: the box takes +/// the typing back, as the key does, and stays open, and the document's waiting Redo stays. The +/// menu applied the typing first (a new edit, which dropped that Redo), closed the box and undid +/// the typing all at once. +#[test] +fn undo_from_the_menu_takes_back_typing_in_the_box() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + h.get_by_label("Bold").click(); + h.run_steps(4); + // Undo the bold (closing the editor), so the document has it to redo and nothing to undo. + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + assert!(h.state_mut().execute("edit.undo"), "Undo is enabled for the box's typing"); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("the box stays open"); + assert_eq!(ed.text, original, "the typing is taken back in the box"); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.can_redo(), doc.can_undo()), (Some("Edit text"), None), "the document is untouched"); +} + /// Redo from the menu after Undo in the box took the typing back, with nothing for the document /// to redo: the menu item follows the box's own Redo too, and puts the typing back. It was /// disabled, as it followed only the document's Redo, and only the key could do it. From 2f50e1fde7f4e5b48710cf1171ea44933cb0bc9d Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:48:04 +1100 Subject: [PATCH 22/82] fix(ui): typing Undo brings back a deleted row's alignment egui's typing history keeps only text and caret, so Undo bringing back a deleted row or line (added text, or a paragraph) gave it the alignment of the row before it, and finishing the box could save that. Each page box now keeps the texts it has shown through its own typing, with every row's alignment then (follow_typing): Undo or Redo back to one of them restores those alignments, and a new text follows the edit with remap. An alignment picked since makes the older ones stale; they are dropped, so Undo never takes back a pick. The record is cleared with the box's typing history and holds 100 texts, as egui's does. It covers typing, the keys and the menu's Undo and Redo. On this branch the two-tab test also runs the menu's Redo in the copy, where the box, with no Redo of its own, passes the step on and closes; that step no longer requires the editor to stay open. Also carries #4179's stronger two-tab test (dd47c4f9): the copy's paragraph must be open before the key Redo and after it. --- crates/ui-egui/src/content_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 124 ++++++++++++++++++++++++++++- crates/ui-egui/src/editing.rs | 12 +-- crates/ui-egui/tests/it/content.rs | 41 ++++++++++ crates/ui-egui/tests/it/editing.rs | 38 ++++++++- 5 files changed, 206 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 969e45a55b..a190e118bb 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -645,7 +645,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } // Each paragraph keeps its own alignment through the typing. if t.text != before { - t.style.aligns = pdfcraft_engine::paragraph_align::remap(&before, &t.text, &t.style.aligns); + crate::edit_text_ui::follow_typing(ui.ctx(), input, &before, &t.text, &mut [&mut t.style.aligns]); } if t.focus { resp.request_focus(); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index dc8958f245..eff2f2631f 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -285,10 +285,59 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcr state.clear_undoer(); egui::TextEdit::store_state(ctx, id, state); } - ctx.data_mut(|d| d.insert_temp(owner, doc)); + ctx.data_mut(|d| { + d.insert_temp(owner, doc); + d.remove::(id.with(ALIGN_HISTORY)); + }); *from = Some(text.to_owned()); } +/// Each text a page box has shown through its own typing, with its rows' alignments then, oldest +/// last: egui's history keeps only the text and the caret. +type AlignHistory = Vec<(String, Vec>>)>; +const ALIGN_HISTORY: &str = "aligns-by-text"; +/// As many texts as egui's own history keeps. +const ALIGN_HISTORY_LEN: usize = 100; + +/// The page text box `id` went from `before` to `after` through its own typing (typing, Undo or +/// Redo), and each list of row alignments in `aligns` follows. A text the box showed before gets +/// back the alignments it had then, so Undo bringing back a deleted row brings back its +/// alignment; a new text follows the edit ([`pdfcraft_engine::paragraph_align::remap`]). An +/// alignment picked since makes the older texts' alignments stale, and they are dropped. +pub(crate) fn follow_typing( + ctx: &egui::Context, + id: egui::Id, + before: &str, + after: &str, + aligns: &mut [&mut Vec>], +) { + let now: Vec> = aligns.iter().map(|a| a.to_vec()).collect(); + let next = ctx.data_mut(|d| { + let seen = d.get_temp_mut_or_default::(id.with(ALIGN_HISTORY)); + if seen.iter().any(|(text, was)| text == before && *was != now) { + seen.clear(); + } + remember(seen, before, now.clone()); + let next = match seen.iter().rev().find(|(text, _)| text == after) { + Some((_, was)) if was.len() == now.len() => was.clone(), + _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap(before, after, a)).collect(), + }; + remember(seen, after, next.clone()); + next + }); + for (slot, a) in aligns.iter_mut().zip(next) { + **slot = a; + } +} + +fn remember(seen: &mut AlignHistory, text: &str, aligns: Vec>>) { + seen.retain(|(t, _)| t != text); + seen.push((text.to_owned(), aligns)); + if seen.len() > ALIGN_HISTORY_LEN { + seen.remove(0); + } +} + /// Select `range` (bytes of `text`) in the page text box `id` again. egui keeps a box's selection /// only while it has the focus, so a click on Format text drops it; given back as formatting /// returns the focus, it stays shown, and a second choice reaches the same text. @@ -441,6 +490,12 @@ impl LineEditor { self.original = self.text.clone(); } + /// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows + /// aligned on their own keep their alignment, and a row Undo brings back gets its own back. + pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) { + follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); + } + /// The text changed from `before`: rows aligned on their own keep their alignment. pub(crate) fn follow_rows(&mut self, before: &str) { for look in [&mut self.look, &mut self.look0] { @@ -1780,7 +1835,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } let r = output.response; if r.changed() { - ed.follow_rows(&before); + ed.follow_typed_rows(ctx, &before); // The next pass measures the updated draft with the Apply/Save rewrite. ctx.request_repaint(); } @@ -1851,6 +1906,71 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + /// Undo back to a text the box showed brings its rows' alignments back, unless an alignment + /// was picked since: then the older alignments are stale, and the edit is followed instead, so + /// Undo never takes back the pick. + #[test] + fn typing_history_restores_alignments_unless_one_was_picked_since() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::new("box")); + let mut aligns = vec![None, Some(Center), Some(Right)]; + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Right)], "the deleted row's entry goes"); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!(aligns, [None, Some(Center), Some(Right)], "Undo brings it back"); + follow_typing( + &ctx, + id, + "A +B +C", + "A +C", + &mut [&mut aligns], + ); + // An alignment picked for the first row, with no typing. + aligns = vec![Some(Right), Some(Right)]; + let picked = aligns.clone(); + follow_typing( + &ctx, + id, + "A +C", + "A +B +C", + &mut [&mut aligns], + ); + assert_eq!( + aligns, + pdfcraft_engine::paragraph_align::remap( + "A +C", "A +B +C", &picked + ), + "the pick stays" + ); + } + #[test] fn the_rotate_knob_moves_below_when_there_is_no_room_above() { let view = Rect::from_min_max(Pos2::new(0.0, 100.0), Pos2::new(500.0, 500.0)); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 6dce0631da..fdb3c15273 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -233,12 +233,14 @@ impl PdfCraftApp { let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; - ed.text = text; + let before = std::mem::replace(&mut ed.text, text); + ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; - d.text = text; + let before = std::mem::replace(&mut d.text, text); + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } false @@ -262,13 +264,13 @@ impl PdfCraftApp { if let Some(ed) = view.line_editor.as_mut() { let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); - ed.follow_rows(&before); + ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut() { let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; - d.style.aligns = pdfcraft_engine::paragraph_align::remap(&d.text, &text, &d.style.aligns); - d.text = text; + let before = std::mem::replace(&mut d.text, text); + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } false diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 327e3f45eb..27b413af53 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1089,6 +1089,47 @@ fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() { assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back"); } +/// Saved added text of three lines aligned Left, Centre and Right: the centred line deleted +/// with its line break, then brought back with Ctrl+Z, is centred again, and Ctrl+Shift+Z deletes +/// it again. The box's history keeps only text, so the restored line took the first line's +/// alignment, and finishing the box would have saved that. +#[test] +fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let mut h = harness(); + let rect = [40.0, 170.0, 240.0, 215.0]; + let aligns = vec![None, Some(Center), Some(Right)]; + let text = pdfcraft_engine::AddedText { rect, text: "A\nB\nC".into(), aligns: aligns.clone(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + // On the first line, at the top of the box. + let point = at(&h, 45.0, 205.0); + click(&mut h, point); + let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.clone().expect("the box is open"); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns.clone()), "the editor opened on the text"); + // Select the second line with its line break, and delete it. + h.run_steps(2); + // To the start of the first line, wherever the click put the caret. + for _ in 0..3 { + h.key_press(egui::Key::ArrowUp); + } + h.key_press(egui::Key::Home); + h.run_steps(1); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowDown); + h.key_press(egui::Key::Backspace); + h.run_steps(2); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "the centred line is gone"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nB\nC".to_string(), aligns), "Undo brings it back centred"); + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + h.run_steps(3); + assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index ceb4c36007..962953d3ef 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6574,6 +6574,36 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() { assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back"); } +/// A row aligned on its own, deleted by typing, then brought back with Undo from the menu: it +/// comes back with its alignment, and Redo deletes it again. The box's history keeps only text, +/// so the restored row took the alignment of the row before it. +#[test] +fn undo_brings_a_deleted_row_back_with_its_alignment() { + let mut h = three_row_paragraph(); + // Right-align the last row on its own. + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_pointer(&mut h, "Align right"); + let ed = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.clone().expect("the paragraph stays open"); + let (three_rows, aligned) = (ed(&h).text, ed(&h).look.aligns); + assert_eq!(aligned.last(), Some(&Some(pdfcraft_engine::TextAlign::Right)), "{aligned:?}"); + // Delete the last row with the line break before it. + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); + h.key_press(Key::Backspace); + h.run_steps(2); + let (two_rows, after_delete) = (ed(&h).text, ed(&h).look.aligns); + assert_eq!(pdfcraft_engine::paragraph_align::count(&two_rows), 2, "{two_rows:?}"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!((ed(&h).text, ed(&h).look.aligns), (three_rows, aligned), "Undo brings the row back right-aligned"); + assert!(h.state_mut().execute("edit.redo")); + h.run_steps(3); + assert_eq!((ed(&h).text, ed(&h).look.aligns), (two_rows, after_delete), "Redo deletes it again"); +} + /// Redo after Undo in the box puts the typing back, in the box: it doesn't reach the document, /// whose own Redo (a formatting change waiting here) must stay waiting. #[test] @@ -6771,12 +6801,14 @@ fn typing_in_one_tab_never_reaches_another_tabs_paragraph() { // The copy, its paragraph still open: Redo there has nothing of its own to put back. h.state_mut().active = Some(1); h.run_steps(3); + assert!(h.state().views[1].line_editor.is_some(), "the copy's paragraph is still open"); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(3); - let text = h.state().views[1].line_editor.as_ref().map(|ed| ed.text.clone()); - assert!(!text.as_deref().unwrap_or_default().contains("secret"), "the other tab's typing came in: {text:?}"); + let text = h.state().views[1].line_editor.as_ref().expect("the copy's paragraph stays open through Redo").text.clone(); + assert!(!text.contains("secret"), "the other tab's typing came in: {text:?}"); // Nor through what the menu's Redo runs, which reads the box's history itself (the menu item - // is off here, as the copy's document has nothing to redo). + // is off here, as the copy's document has nothing to redo). With no Redo of its own, the box + // passes the step to the document and closes, so the page's text is checked too. h.state_mut().redo(); h.run_steps(3); let s = h.state(); From c9abf15e7c503c5b794f1dfd8a2f245da889040b Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:57:41 +1100 Subject: [PATCH 23/82] fix(edit): only a row aligned on its own is rewritten A nonempty aligns counted as a style change, so every row of the paragraph was rebuilt from the text state: rows left unselected lost their source operators, and with TJ word gaps their words moved by about a point and a half while the row's outer width stayed the same. aligns no longer makes an edit non-plain; a row whose own line has an alignment override is rewritten, and the others keep their operators. --- crates/edit/src/tests.rs | 20 ++++++++++++++++++++ crates/edit/src/text_regions.rs | 12 +++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index f12430561e..afd908f86c 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6975,6 +6975,26 @@ fn one_row_of_a_paragraph_takes_its_own_alignment() { assert!((row("eight")[2] - block.rect[2]).abs() < 1.0, "the second row ends at the right edge: {:?} vs {}", row("eight"), block.rect[2]); } +/// Only a row given an alignment of its own is rewritten. The others keep their source operators, +/// `TJ` word gaps included, so their words stay exactly where they were: rebuilt from the text, +/// the first row's words moved by about a point and a half while its outer width stayed the same. +#[test] +fn rows_without_their_own_alignment_keep_their_operators() { + let first = "[(One) -250 (two) -250 (three) -250 (four)] TJ"; + let mut doc = text_page(&format!("BT /F1 10 Tf 12 TL 72 700 Td {first} T* [(five) -250 (six) -250 (seven) -250 (eight)] TJ T* (nine ten) Tj ET")); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + assert_eq!(block.editor_text.matches('\n').count(), 2, "one paragraph of three rows: {:?}", block.editor_text); + let row = |doc: &Document, starts: &str| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text.starts_with(starts)).expect("the row"); + let ink = row(&doc, "One").ink; + let style = text::BlockStyle { aligns: vec![None, None, Some(added::Align::Right)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap(); + let doc = reopen(&doc); + assert_eq!(row(&doc, "One").ink, ink, "the first row's words stay where they were"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains(first), "the first row keeps its operator: {content}"); + assert!((row(&doc, "nine").rect[2] - block.rect[2]).abs() < 1.0, "the last row is right-aligned: {:?}", row(&doc, "nine").rect); +} + /// The same in a region of several styles: only the short first row is centred. #[test] fn one_row_of_a_styled_region_takes_its_own_alignment() { diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 5d0a01e97d..965792908b 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -874,10 +874,16 @@ pub(super) fn rewrite_styled_block( let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and - // glyph widths stay exactly as they were instead of being rebuilt from the text state. - let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); + // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row + // aligned on its own is reached; the rows around it are not. + let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); let mut kept = if plain { - untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() + untouched_rows(block, draft, &row_ranges) + .into_iter() + .zip(&rows) + .zip(&row_lines) + .map(|((same, row), line)| same && row.iter().all(|c| c.1 < source_count) && style.aligns.get(*line).copied().flatten().is_none()) + .collect() } else { vec![false; rows.len()] }; From bfc6f8b1b97e75a635b7b3beff01cc03c8c91171 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:10:46 +1100 Subject: [PATCH 24/82] fix(ui): a text box keeps its typing history through a trip to another tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tabs share one text box, and its Undo history was cleared whenever another document's box took it: opening a paragraph in tab B wiped tab A's typing steps, so Edit ▸ Undo back in A found none and closed A's paragraph instead of taking the typing back. The box being left now keeps a copy of its history and caret aside, under its document, and the same box gets it back on its return; a box new to the text box still starts with none, so typing still never reaches another tab. The copy is a deep one: a state's history is shared between its clones. Carried from #4179 (abc378c0). On this branch the rows' alignments by text go aside and come back with the history, so Undo after the trip still restores a deleted row's alignment. --- crates/ui-egui/src/edit_text_ui.rs | 54 +++++++++++++++++++++++++++--- crates/ui-egui/tests/it/content.rs | 36 ++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 43 ++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index eff2f2631f..15cc0dfc00 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -276,18 +276,41 @@ pub(crate) fn input_id() -> egui::Id { /// as it was before a format change, into a box showing the page as it is now. `from` is the box's /// own record of that; which document `doc` last used the history is kept beside it, since a box /// in another tab on the same text would otherwise take that document's typing with its Redo. +/// When another document's box takes the text box, the box it leaves keeps its history (and +/// caret) aside, and gets it back on its return, so its typing can still be undone there. pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { let owner = id.with("typing-doc"); - if from.as_deref() == Some(text) && ctx.data(|d| d.get_temp::(owner)) == Some(doc) { + let last = ctx.data(|d| d.get_temp::(owner)); + if from.as_deref() == Some(text) && last == Some(doc) { return; } - if let Some(mut state) = egui::TextEdit::load_state(ctx, id) { - state.clear_undoer(); - egui::TextEdit::store_state(ctx, id, state); + let aside = |doc: pdfcraft_engine::DocId| id.with(("typing-aside", doc)); + let aligns = id.with(ALIGN_HISTORY); + if let Some(left) = last.filter(|l| *l != doc) + && let Some(state) = egui::TextEdit::load_state(ctx, id) + { + // A copy of its own: a state's history is shared between its clones, and the next box + // clears it in place. The rows' alignments by text go with it. + let mut kept = egui::text_edit::TextEditState::default(); + kept.cursor = state.cursor; + kept.set_undoer(state.undoer()); + ctx.data_mut(|d| { + let rows = d.get_temp::(aligns).unwrap_or_default(); + d.insert_temp(aside(left), (kept, rows)); + }); } + // Only the same box, still on the text it opened on, takes its history back. + let back = + ctx.data_mut(|d| d.remove_temp::<(egui::text_edit::TextEditState, AlignHistory)>(aside(doc))).filter(|_| from.as_deref() == Some(text)); + let (state, rows) = back.unwrap_or_else(|| { + let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); + state.clear_undoer(); + (state, AlignHistory::default()) + }); + egui::TextEdit::store_state(ctx, id, state); ctx.data_mut(|d| { d.insert_temp(owner, doc); - d.remove::(id.with(ALIGN_HISTORY)); + d.insert_temp(aligns, rows); }); *from = Some(text.to_owned()); } @@ -1906,6 +1929,27 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + /// The rows' alignments by text go aside with the box's history when another tab's box takes + /// the text box, and come back with it: Undo after the trip still restores a deleted row's + /// alignment, while the other tab's box starts with none. + #[test] + fn alignments_by_text_survive_a_trip_to_another_tab() { + use pdfcraft_engine::{DocId, TextAlign::Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::new("box")); + let (mut a_from, mut b_from) = (None, None); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + let mut aligns = vec![None, Some(Right)]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); + forget_typing_before(&ctx, id, DocId(2), &mut b_from, "A\nB"); + let mut other = vec![None, None]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut other]); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut other]); + assert_eq!(other, [None, None], "tab B's box gets back its own alignments, not tab A's"); + forget_typing_before(&ctx, id, DocId(1), &mut a_from, "A\nB"); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); + assert_eq!(aligns, [None, Some(Right)], "back in tab A, Undo brings the row back right-aligned"); + } + /// Undo back to a text the box showed brings its rows' alignments back, unless an alignment /// was picked since: then the older alignments are stale, and the edit is followed instead, so /// Undo never takes back the pick. diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 27b413af53..e7210a8ac9 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1130,6 +1130,42 @@ fn undo_brings_a_deleted_line_of_added_text_back_with_its_alignment() { assert_eq!((draft(&h).text, draft(&h).style.aligns), ("A\nC".to_string(), vec![None, Some(Right)]), "Redo deletes it again"); } +/// Typing in tab A's added text box, the same text opened in tab B, then back to A: Edit ▸ Undo +/// takes the typing back in A's box, which stays open. +#[test] +fn undo_after_a_round_trip_through_another_tab_takes_back_added_text_typing() { + let mut h = harness(); + h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(4); + let text = pdfcraft_engine::AddedText { rect: [40.0, 195.0, 240.0, 210.0], text: "Reviewed".into(), ..Default::default() }; + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); + assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + }; + for tab in [0, 1] { + h.state_mut().active = Some(tab); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text: text.clone() })); + } + open_in(&mut h, 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + open_in(&mut h, 1); + h.state_mut().active = Some(0); + h.run_steps(3); + let draft = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); + assert_eq!(draft(&h).as_deref(), Some("Reviewed twice"), "A's box is still open with the typing"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(draft(&h).as_deref(), Some("Reviewed"), "Undo takes the typing back in A's box"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 962953d3ef..a92850a275 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6770,6 +6770,49 @@ fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() { assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back"); } +/// Typing in tab A's paragraph, a paragraph opened in tab B, then back to A: Edit ▸ Undo takes +/// the typing back in A's box. The tabs share one text box, and its history was cleared each time +/// another tab's box took it, so Undo found no step and closed A's paragraph instead +/// (skylartaylor's and FredApps's review of 08ec1db7). +#[test] +fn undo_after_a_round_trip_through_another_tab_takes_back_the_typing() { + let mut h = harness(1, |app| { + app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens"); + }); + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + if h.state().views[tab].line_editor.is_none() { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + } + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height())); + assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing"); + }; + open_in(&mut h, 0); + h.get_by_label("Bold").click(); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + open_in(&mut h, 0); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + assert!(h.state().views[0].line_editor.as_ref().unwrap().text.ends_with(" more")); + open_in(&mut h, 1); + h.state_mut().active = Some(0); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some(), "A's paragraph is still open after the round trip"); + assert!(h.state_mut().execute("edit.undo"), "Undo is enabled"); + h.run_steps(3); + let s = h.state(); + let ed = s.views[0].line_editor.as_ref().expect("the box stays open: Undo takes back the typing"); + assert_eq!(ed.text, original, "the typing is taken back in the box"); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); +} + /// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text /// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it. /// Typing in one tab, taken back with Undo, then came back with Redo (the key or the menu) in the From e6e57a9a07471c2bc7d9051a30b08d0a60e3b123 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:15:26 +1100 Subject: [PATCH 25/82] fix(ui): formatting restarts a text box's history; the menu claims the box first Three holes in the shared text box's history: - Formatting applied with the paragraph open left typing_from as it was, so when the format left the text unchanged the box kept its history, and Redo put typing undone before the format back on top of it. applied() now resets typing_from, so the next claim clears it. - Undo or Redo from the menu in the same frame as a tab switch ran before the box was drawn, on the history of the other tab's box: Undo put tab B's text into tab A's box, and Redo put A's undone typing into B's. undo_in_page_box and redo_in_page_box now make the box claim the text box first (claim_typing), as drawing it does. - A box new to the text box cleared only the last box's history and kept its caret and selection; it now starts from a default state. claim_typing on LineEditor and TextDraft is now the one way a box takes the text box, for drawing and for the menu. Carried from #4179 (aa326f37). On this branch the rows' alignments by text restart with the history, so a Redo past a format can't bring back an alignment from before it. --- crates/ui-egui/src/content_ui.rs | 8 ++- crates/ui-egui/src/edit_text_ui.rs | 40 +++++++++-- crates/ui-egui/src/editing.rs | 6 ++ crates/ui-egui/tests/it/content.rs | 36 ++++++++++ crates/ui-egui/tests/it/editing.rs | 110 +++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index a190e118bb..d6ac839f08 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -78,6 +78,12 @@ pub struct TextDraft { } impl TextDraft { + /// Take the shared text box for this box ([`crate::edit_text_ui::forget_typing_before`]): its + /// own history, or none once the page has its text. + pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) { + crate::edit_text_ui::forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.style.text); + } + /// Typing the page doesn't have yet: Undo takes it back in the box before touching the document. pub(crate) fn has_unapplied_typing(&self) -> bool { self.text != self.style.text @@ -624,7 +630,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad }; let before = t.text.clone(); let input = input_id(); - crate::edit_text_ui::forget_typing_before(ui.ctx(), input, doc_id, &mut t.typing_from, &t.style.text); + t.claim_typing(ui.ctx(), doc_id); ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; let output = egui::TextEdit::multiline(&mut t.text) diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 15cc0dfc00..69b08e49e6 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -277,7 +277,9 @@ pub(crate) fn input_id() -> egui::Id { /// own record of that; which document `doc` last used the history is kept beside it, since a box /// in another tab on the same text would otherwise take that document's typing with its Redo. /// When another document's box takes the text box, the box it leaves keeps its history (and -/// caret) aside, and gets it back on its return, so its typing can still be undone there. +/// caret) aside, and gets it back on its return, so its typing can still be undone there. Each +/// box claims the text box when it is drawn, and Undo and Redo from the menu claim it first too, +/// since they can run before the box is drawn again after a tab switch. pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcraft_engine::DocId, from: &mut Option, text: &str) { let owner = id.with("typing-doc"); let last = ctx.data(|d| d.get_temp::(owner)); @@ -302,11 +304,9 @@ pub(crate) fn forget_typing_before(ctx: &egui::Context, id: egui::Id, doc: pdfcr // Only the same box, still on the text it opened on, takes its history back. let back = ctx.data_mut(|d| d.remove_temp::<(egui::text_edit::TextEditState, AlignHistory)>(aside(doc))).filter(|_| from.as_deref() == Some(text)); - let (state, rows) = back.unwrap_or_else(|| { - let mut state = egui::TextEdit::load_state(ctx, id).unwrap_or_default(); - state.clear_undoer(); - (state, AlignHistory::default()) - }); + // A box new to the text box starts with nothing of the last one's: no history, caret, + // selection or rows' alignments by text. + let (state, rows) = back.unwrap_or_default(); egui::TextEdit::store_state(ctx, id, state); ctx.data_mut(|d| { d.insert_temp(owner, doc); @@ -496,9 +496,18 @@ impl LineEditor { self.look0 = self.look.clone(); self.extras0 = self.extras; self.original = self.text.clone(); + // The page now has this text: the box's history restarts, even when the format left the + // text as it was, so a Redo from before can't put undone typing back on top of it. + self.typing_from = None; self.focus = true; } + /// Take the shared text box for this paragraph ([`forget_typing_before`]): its own history, + /// or none once the page has its text. + pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) { + forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.original); + } + /// Adopt the rewritten paragraph's current geometry before the next overlay frame. pub(crate) fn refresh_source(&mut self, block: &pdfcraft_engine::TextBlock, lines: Vec) { self.source_rect = block.rect.map(|v| v as f32); @@ -1832,7 +1841,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - }; let before = ed.text.clone(); let input = input_id(); - forget_typing_before(ui.ctx(), input, view_id, &mut ed.typing_from, &ed.original); + ed.claim_typing(ui.ctx(), view_id); let output = egui::TextEdit::multiline(&mut ed.text) .id(input) .font(font.clone()) @@ -1929,6 +1938,23 @@ pub(crate) fn caret_color_on(fill: Color32) -> Color32 { mod tests { use super::*; + /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo + /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). + #[test] + fn formatting_applied_restarts_the_alignments_by_text() { + use pdfcraft_engine::{DocId, TextAlign::Right}; + let (ctx, id) = (egui::Context::default(), egui::Id::new("box")); + let mut from = None; + forget_typing_before(&ctx, id, DocId(1), &mut from, "A\nB"); + let mut aligns = vec![None, Some(Right)]; + follow_typing(&ctx, id, "A\nB", "A", &mut [&mut aligns]); + // Formatting applied: the page now has "A", and the box's record of its text is reset. + from = None; + forget_typing_before(&ctx, id, DocId(1), &mut from, "A"); + follow_typing(&ctx, id, "A", "A\nB", &mut [&mut aligns]); + assert_eq!(aligns, pdfcraft_engine::paragraph_align::remap("A", "A\nB", &[None]), "the row before the format doesn't come back aligned"); + } + /// The rows' alignments by text go aside with the box's history when another tab's box takes /// the text box, and come back with it: Undo after the trip still restores a deleted row's /// alignment, while the other tab's box starts with none. diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index fdb3c15273..af6e01eb81 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -231,13 +231,16 @@ impl PdfCraftApp { fn undo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { + ed.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; let before = std::mem::replace(&mut ed.text, text); ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { + d.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; let before = std::mem::replace(&mut d.text, text); crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); @@ -261,13 +264,16 @@ impl PdfCraftApp { fn redo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; if let Some(ed) = view.line_editor.as_mut() { + ed.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); ed.follow_typed_rows(&ctx, &before); return true; } if let Some(d) = view.content.draft.as_mut() { + d.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; let before = std::mem::replace(&mut d.text, text); crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index e7210a8ac9..4fc6c59b67 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1166,6 +1166,42 @@ fn undo_after_a_round_trip_through_another_tab_takes_back_added_text_typing() { assert_eq!(draft(&h).as_deref(), Some("Reviewed"), "Undo takes the typing back in A's box"); } +/// Edit ▸ Undo chosen in the same frame as the switch back to tab A's added text box, before it +/// is drawn again: the shared text box still holds tab B's history, from a box on other text, so +/// A's box claims its own back before stepping. +#[test] +fn undo_in_the_frame_of_the_switch_back_takes_back_added_text_typing() { + let mut h = harness(); + h.state_mut().open_bytes("copy.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(4); + let rect = [40.0, 195.0, 240.0, 210.0]; + for (tab, text) in [(0, "Reviewed"), (1, "Approved")] { + h.state_mut().active = Some(tab); + let text = pdfcraft_engine::AddedText { rect, text: text.into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + } + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + click(h, r.min + egui::vec2(45.0 * k, 195.0 * k)); + assert_eq!(h.state().views[tab].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + }; + open_in(&mut h, 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + open_in(&mut h, 1); + // Back to A and Undo at once: no frame draws A's box in between. + h.state_mut().active = Some(0); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes the typing back in A's box"); +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index a92850a275..767aa18062 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6813,6 +6813,116 @@ fn undo_after_a_round_trip_through_another_tab_takes_back_the_typing() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_redo(), Some("Edit text"), "the document's Redo still waits"); } +/// Typing taken back with Ctrl+Z, then Bold applied with the paragraph still open: Ctrl+Shift+Z +/// must not bring the typing back on top of the bold, as the box's history is cleared once the +/// page has its text. The format left the text as it was, so the box kept its history and its +/// Redo put "Page 1 more" back (FredApps's review of abc378c0). +#[test] +fn redo_after_formatting_never_brings_back_typing_undone_before_it() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + open_paragraph(&mut h); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); + h.get_by_label("Bold").click(); + h.run_steps(4); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit text"), "the bold is applied"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(3); + let text = h.state().views[0].line_editor.as_ref().map(|ed| ed.text.clone()); + assert_eq!(text.as_deref(), Some(original.as_str()), "the typing undone before the bold stays undone"); + // Nor through what the menu's Redo runs. + h.state_mut().redo(); + h.run_steps(3); + let s = h.state(); + let text = s.views[0].line_editor.as_ref().map(|ed| ed.text.clone()); + assert!(!text.as_deref().unwrap_or_default().contains("more"), "the menu's Redo brings it back: {text:?}"); + assert_eq!(s.session.get(s.views[0].id).unwrap().text_blocks(0)[0].base_font, "Helvetica-Bold", "the bold stays"); +} + +/// Edit ▸ Undo chosen in the same frame as the switch back to tab A, before A's box is drawn +/// again: the shared text box still holds tab B's history then. Stepping through it put B's +/// typing into A's box; the box now claims its own history back first (CodeRabbit on abc378c0). +#[test] +fn undo_in_the_frame_of_the_switch_back_takes_back_the_typing() { + let mut h = harness(1, |app| { + app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens"); + }); + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + if h.state().views[tab].line_editor.is_none() { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + } + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height())); + assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing"); + }; + open_in(&mut h, 0); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" more".into())); + h.run_steps(2); + // In B, a paragraph whose text differs from A's ("Page 1 B", applied with Bold), so B's + // history can't pass for A's. + open_in(&mut h, 1); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" B".into())); + h.run_steps(2); + h.get_by_label("Bold").click(); + h.run_steps(4); + assert_eq!(h.state().views[1].line_editor.as_ref().map(|ed| ed.text.as_str()), Some("Page 1 B"), "B's paragraph is open on its own text"); + // Back to A and Undo at once: no frame draws A's box in between. + h.state_mut().active = Some(0); + assert!(h.state_mut().execute("edit.undo"), "Undo is enabled"); + h.run_steps(3); + let ed = h.state().views[0].line_editor.clone().expect("the box stays open: Undo takes back the typing"); + assert_eq!(ed.text, original, "the typing is taken back in A's box"); +} + +/// Redo from the menu chosen in the same frame as a switch to tab B, whose paragraph is open on +/// the same text as A's: the shared text box still holds A's history then, with A's undone +/// typing to redo, so B's box claims its own history back before stepping. +#[test] +fn redo_in_the_frame_of_a_tab_switch_never_brings_another_tabs_typing() { + let mut h = harness(1, |app| { + app.open_bytes("copy.pdf", None, fixture(1)).expect("the copy opens"); + }); + let open_in = |h: &mut Harness<'static, PdfCraftApp>, tab: usize| { + h.state_mut().active = Some(tab); + h.run_steps(2); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[tab].page_screen_rect(0).expect("on screen"); + click_at(h, egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height())); + assert!(h.state().views[tab].line_editor.is_some(), "the paragraph opens for typing"); + }; + open_in(&mut h, 1); + open_in(&mut h, 0); + let original = h.state().views[0].line_editor.as_ref().unwrap().text.clone(); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.event(egui::Event::Text(" secret".into())); + h.run_steps(2); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(3); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "taken back in A"); + // To B and Redo at once: no frame draws B's box in between. + h.state_mut().active = Some(1); + h.state_mut().redo(); + h.run_steps(3); + let s = h.state(); + let b = s.views[1].line_editor.as_ref().map(|ed| ed.text.clone()); + assert!(!b.as_deref().unwrap_or_default().contains("secret"), "A's typing came back in B: {b:?}"); + assert!(!s.session.get(s.views[1].id).unwrap().text_blocks(0)[0].text.contains("secret"), "A's typing reached B's page"); +} + /// Two copies of a document, the same paragraph open in each. The paragraph boxes share one text /// box, and so one Undo history: a box opened on the same text as another tab's didn't clear it. /// Typing in one tab, taken back with Undo, then came back with Redo (the key or the menu) in the From a85a6c721172dda6e9e567b379094f73a999b16c Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:22:57 +1100 Subject: [PATCH 26/82] fix(ui): restyling an open added-text box restarts its typing history restyle_draft applies a format change to an open added-text box in place, so the page then has the box's text. Unlike a paragraph's applied(), it left typing_from as it was: when the restyle left the text as it was, the box kept its history, and Redo put typing undone before the restyle back on top of it, to be saved on finishing. It now resets typing_from after a successful edit, so the next claim clears the history (and the rows' alignments by text with it). --- crates/ui-egui/src/panels.rs | 3 +++ crates/ui-egui/tests/it/content.rs | 40 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0178325b0b..9c595dee84 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -674,6 +674,9 @@ impl PdfCraftApp { { // What the page has now. The aligns stay those of the text in the box. d.style = pdfcraft_engine::AddedText { aligns: std::mem::take(&mut d.style.aligns), ..updated }; + // The page has the box's text: its typing history restarts at the next claim, even + // when the restyle left the text as it was, so a Redo can't put undone typing back. + d.typing_from = None; } } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index acc9d80472..69597591db 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1241,6 +1241,46 @@ fn undo_in_the_frame_of_the_switch_back_takes_back_added_text_typing() { assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes the typing back in A's box"); } +/// Retyping taken back with Ctrl+Z, then Bold picked with the added text box still open: the +/// restyle is applied in place and the page has the box's text, so neither Ctrl+Shift+Z nor the +/// menu's Redo may bring the undone retyping back on top of the bold (CodeRabbit on a33a8bba). +#[test] +fn redo_after_restyling_open_added_text_never_brings_back_typing_undone_before_it() { + for route in ["key", "menu"] { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Reviewed".into(), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.event(egui::Event::Text(" twice".into())); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "{route}: taken back"); + h.get_by_label("Bold").click(); + h.run_steps(4); + let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; + assert!(now.bold, "{route}: the bold is applied to the item"); + assert!(h.state().views[0].content.draft.is_some(), "{route}: the box stays open"); + if route == "key" { + h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::Z); + } else { + // The menu item is off: neither the document nor the box has a Redo. + assert!(!h.state_mut().execute("edit.redo"), "menu: Redo is refused, there is nothing to redo"); + } + h.run_steps(3); + let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone()); + assert_eq!(draft.as_deref(), Some("Reviewed"), "{route}: the retyping undone before the bold comes back in the box"); + let pdfcraft_engine::AddedContent::Text(now) = &added(&h)[0].content else { panic!() }; + assert_eq!(now.text, "Reviewed", "{route}: nor in the document"); + } +} + /// Redo with an added text box open and unchanged reaches the document's waiting Redo, even when /// the box's text ends in a space: closing the unchanged box writes nothing, so it can't clear /// that Redo first. From 7679f6b5562174cd1e9383ba84b2c2e4092f0706 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:02:30 +1100 Subject: [PATCH 27/82] fix(edit): keep column frames local and clear stale history selections --- crates/edit/src/tests.rs | 44 ++++++++++++++++++++++++ crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/grouping.rs | 37 ++++++++++++-------- crates/ui-egui/src/editing.rs | 4 +++ crates/ui-egui/tests/it/content.rs | 11 ++++++ crates/ui-egui/tests/it/editing.rs | 11 ++++++ 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0859560620..96f71da36b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7259,6 +7259,50 @@ fn a_lone_row_of_two_styles_aligns_across_its_column() { assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); } +#[test] +fn a_titles_column_ignores_distant_full_width_headers_and_footers() { + for (title_x, column_prefix) in [(95, "The first"), (365, "The second")] { + for mixed in [false, true] { + for title_y in [710, 580] { + let title = if mixed { "(Big ) Tj 1 0 0 rg (Title) Tj" } else { "(Title) Tj" }; + let name = if mixed { "Big Title" } else { "Title" }; + let mut doc = text_page(&format!( + "BT /F1 10 Tf 12 TL 50 780 Td (A page-wide header extending well beyond either body column on this page and continuing all the way to the right margin.) Tj T* (A repeated page-wide heading across both columns and continuing to the right margin of the page.) Tj ET \ + BT /F1 18 Tf 1 0 0 1 {title_x} {title_y} Tm {title} ET \ + BT 0 g /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column runs to here.) Tj T* (Another row.) Tj ET \ + BT /F1 10 Tf 50 40 Td (A page-wide footer extending well beyond either body column on this page and continuing all the way to the right margin.) Tj ET" + )); + let rect = |doc: &Document, prefix: &str| { + let blocks = text::text_blocks(doc, 0).unwrap(); + blocks.iter().find(|b| b.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?} in {blocks:?}")).rect + }; + let column = rect(&doc, column_prefix); + let header = rect(&doc, "A page-wide header"); + let footer = rect(&doc, "A page-wide footer"); + assert!(header[2] > column[2] + 20.0 && footer[2] > column[2] + 20.0); + for align in [added::Align::Center, added::Align::Center, added::Align::Right, added::Align::Left] { + let blocks = text::text_blocks(&doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == name).unwrap(); + let style = text::BlockStyle { aligns: vec![Some(align)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some(&block.editor_text), &style).unwrap(); + doc = reopen(&doc); + let title = rect(&doc, name); + let error = match align { + added::Align::Center => ((title[0] + title[2]) - (column[0] + column[2])).abs() / 2.0, + added::Align::Right => (title[2] - column[2]).abs(), + _ => (title[0] - column[0]).abs(), + }; + assert!(error < 1.0, "{align:?}, mixed={mixed}, title_y={title_y}: {title:?} must use {column:?}"); + assert_eq!(rect(&doc, "A page-wide header"), header); + assert_eq!(rect(&doc, "A page-wide footer"), footer); + assert_eq!(rect(&doc, column_prefix), column); + } + } + } + } +} + /// How each glyph-showing operator on page 0 is painted, read the way a renderer reads the page: /// its text, `Tr`, stroke colour operators, line width (from `w` or an ExtGState's `/LW`) and /// the ExtGStates selected before it, following `q`/`Q`. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 77d3d8b2a3..5cb5f2a321 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -221,7 +221,7 @@ pub(crate) fn rewrite_block_from( // An alignment for a one-row paragraph is laid out across the column it sits in, as a // paragraph of that width moved to its left edge. let framed; - let style = match alignment_frame(&lines, &b, style) { + let style = match alignment_frame(&lines, &blocks, &b, style) { Some([left, right]) => { framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; &framed diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index fce75880d3..4f2e8371a3 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -28,12 +28,11 @@ fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { /// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row /// that does not share it is not merely an indented or full-width left-aligned line, so its own -/// centre is the only remaining explanation for where it sits. With `over`, only rows reaching -/// across that span count: the edge of the column under it, however many rows another column has. -fn neighbour_left(lines: &[TextLine], block: &TextBlock, over: Option<[f64; 2]>) -> Option { +/// centre is the only remaining explanation for where it sits. +fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { let mut edges: HashMap = HashMap::new(); for (i, line) in lines.iter().enumerate() { - if block.lines.contains(&i) || over.is_some_and(|[left, right]| line.rect[2] <= left || line.rect[0] >= right) { + if block.lines.contains(&i) { continue; } // Quarter-point buckets: equal edges found through different runs still agree. @@ -44,25 +43,33 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock, over: Option<[f64; 2]>) } /// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of -/// text it sits in (the left edge the text under or over it uses, to the furthest right those rows -/// reach) when that column holds it, otherwise its own box. A lone row's own box is no frame +/// text it sits in (the nearest overlapping paragraph's left edge, to the furthest right +/// its rows reach) when that column holds it, otherwise its own box. A lone row's own box is no frame /// to align in: left-aligning a title centred on the page left it where it was, and since a single /// row grows to the page's margin, centring it again moved it right each time. `None` when no /// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a /// move of its own. -pub(crate) fn alignment_frame(lines: &[TextLine], block: &TextBlock, style: &BlockStyle) -> Option<[f64; 2]> { +pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle) -> Option<[f64; 2]> { let asked = style.align.is_some() || style.aligns.iter().any(Option::is_some); if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { return None; } let own = [block.rect[0], block.rect[2]]; let tolerance = block.size.max(1.0) * 0.5; - let Some(left) = neighbour_left(lines, block, Some(own)) else { return Some(own) }; - let right = lines + let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0); + // Page-wide headers and footers sharing this margin aren't part of the local column. + let neighbour = blocks .iter() - .enumerate() - .filter(|(i, line)| !block.lines.contains(i) && (line.rect[0] - left).abs() < tolerance) - .map(|(_, line)| line.rect[2]) + .filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1]) + .min_by(|a, b| gap(a).total_cmp(&gap(b))); + let Some(neighbour) = neighbour else { return Some(own) }; + let left = neighbour.rect[0]; + let right = neighbour + .lines + .iter() + .filter_map(|i| lines.get(*i)) + .filter(|line| (line.rect[0] - left).abs() < tolerance) + .map(|line| line.rect[2]) .fold(f64::NEG_INFINITY, f64::max); let holds = right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance; Some(if holds { [left, right] } else { own }) @@ -84,9 +91,9 @@ pub(crate) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64 return left_span > size * 0.5 && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size)); } - lefts.first().is_some_and(|left| { - (centre - page_centre).abs() < size && neighbour_left(lines, block, None).is_none_or(|edge| (*left - edge).abs() > size * 0.5) - }) + lefts + .first() + .is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5)) } pub(crate) fn group_blocks(lines: &[TextLine]) -> Vec { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index fc8d637a70..17366df250 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -220,6 +220,7 @@ impl PdfCraftApp { ed.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; let before = std::mem::replace(&mut ed.text, text); + ed.selection = None; ed.follow_typed_rows(&ctx, &before); return true; } @@ -227,6 +228,7 @@ impl PdfCraftApp { d.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; let before = std::mem::replace(&mut d.text, text); + d.selection = None; crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } @@ -253,6 +255,7 @@ impl PdfCraftApp { ed.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); + ed.selection = None; ed.follow_typed_rows(&ctx, &before); return true; } @@ -260,6 +263,7 @@ impl PdfCraftApp { d.claim_typing(&ctx, doc); let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; let before = std::mem::replace(&mut d.text, text); + d.selection = None; crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); return true; } diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 69597591db..1dadc6adaa 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1096,7 +1096,13 @@ fn undo_from_the_menu_takes_back_retyping_in_the_added_text_box() { h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); h.event(egui::Event::Text(" twice".into())); h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); + h.run_steps(2); + assert!(h.state().views[0].content.draft.as_ref().unwrap().selection.is_some()); + h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.undo")); + assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); let s = h.state(); @@ -1123,7 +1129,12 @@ fn redo_from_the_menu_is_enabled_for_the_added_text_boxs_own_typing() { h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed"), "Undo takes it back in the box"); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo"); + // A range retained while the unfocused editor still showed its previous text. + h.state_mut().views[0].content.draft.as_mut().unwrap().selection = Some(0..1); + h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step"); + assert_eq!(h.state().views[0].content.draft.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Reviewed twice"), "Redo puts it back"); } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 03154369ab..e69eb63d85 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6742,7 +6742,13 @@ fn undo_from_the_menu_takes_back_typing_in_the_box() { h.key_press_modifiers(Modifiers::COMMAND, Key::End); h.event(egui::Event::Text(" more".into())); h.run_steps(2); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + assert!(h.state().views[0].line_editor.as_ref().unwrap().selection.is_some()); + h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.undo"), "Undo is enabled for the box's typing"); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); let s = h.state(); let ed = s.views[0].line_editor.as_ref().expect("the box stays open"); @@ -6769,7 +6775,12 @@ fn redo_from_the_menu_is_enabled_for_the_boxs_own_typing() { h.run_steps(3); assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().text, original, "the typing is taken back"); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_redo(), None, "the document has nothing to redo"); + // A range retained while the unfocused editor still showed its previous text. + h.state_mut().views[0].line_editor.as_mut().unwrap().selection = Some(0..1); + h.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("test-menu"))); assert!(h.state_mut().execute("edit.redo"), "Redo is enabled for the box's own step"); + assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, None, "menu history clears the old byte range before the next frame"); h.run_steps(3); assert_eq!(h.state().views[0].line_editor.as_ref().expect("still open").text, typed, "Redo puts the typing back"); } From b9940d7d8104763d5e7106543dc6b41acce942cd Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:38:05 +1100 Subject: [PATCH 28/82] fix(edit): a lone row aligns in its own column, not a closer page-wide heading alignment_frame measured a one-row paragraph's alignment in the frame of the nearest overlapping block. A full-width heading closer to the title than the title's own body column gave it a page-wide frame, so centring or right-aligning the title moved it out of its column. It now considers every overlapping block whose column holds the row and takes the narrowest, the nearest on a tie, so a page-wide heading gives the title no frame while a narrower column that holds it does. When no column holds the row it is still measured in its own box. The comment about page-wide headers named an exclusion the filter never made; the doc comment now says what is chosen and why. --- crates/edit/src/tests.rs | 27 +++++++++++++++++ crates/edit/src/text_regions/grouping.rs | 37 ++++++++++++++---------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index be82ae144d..48df3e4395 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7255,6 +7255,33 @@ fn a_justified_paragraph_from_the_editor_fills_its_rows() { assert!(row("end")[2] < right - 20.0, "the last row isn't"); } +/// A heading across the whole page sits closer to the title than the title's own column does: the +/// title is still aligned in its own column, not across the heading's page-wide frame (FredApps's +/// review of 43f848d5). Centre and right both go by the column. +#[test] +fn a_lone_row_aligns_in_its_own_column_not_a_closer_full_width_heading() { + let page = "BT /F1 12 Tf 1 0 0 1 50 700 Tm (A heading that runs across both columns of the page, well past the first column, to the far margin.) Tj ET BT /F1 18 Tf 1 0 0 1 120 672 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET BT /F1 10 Tf 12 TL 320 610 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let heading = blocks.iter().find(|b| b.text.starts_with("A heading")).unwrap().rect; + let title_block = blocks.iter().find(|b| b.text == "Title").expect("the title is a block of its own").rect; + let gap = |other: [f64; 4]| (other[1] - title_block[3]).max(title_block[1] - other[3]).max(0.0); + assert!(gap(heading) < gap(column), "the heading is the nearer block: {} vs {}", gap(heading), gap(column)); + assert!(heading[2] - heading[0] > 2.0 * (column[2] - column[0]), "the heading is page-wide: {heading:?} vs {column:?}"); + let title_after = |align: added::Align| { + let mut doc = text_page(page); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { aligns: vec![Some(align)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect + }; + let centred = title_after(added::Align::Center); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[2]) / 2.0).abs() < 1.0, "centred in its own column: {centred:?} vs {column:?}"); + let right = title_after(added::Align::Right); + assert!((right[2] - column[2]).abs() < 1.0, "right: on its own column's right edge, {right:?} vs {column:?}"); +} + /// The same on a page of two columns, where the other column has more rows: a title over the /// first column is aligned across the first column, not its own box. #[test] diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 357f1f7244..2e44290117 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -43,8 +43,10 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { } /// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of -/// text it sits in (the nearest overlapping paragraph's left edge, to the furthest right -/// its rows reach) when that column holds it, otherwise its own box. A lone row's own box is no frame +/// text it sits in (an overlapping paragraph's left edge, to the furthest right its rows reach) +/// when that column holds it, otherwise its own box. Of the columns that hold it, the narrowest is +/// its own, the nearest on a tie: a heading across the whole page, even one closer than the body, +/// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame /// to align in: left-aligning a title centred on the page left it where it was, and since a single /// row grows to the page's margin, centring it again moved it right each time. `None` when no /// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a @@ -57,22 +59,25 @@ pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: & let own = [block.rect[0], block.rect[2]]; let tolerance = block.size.max(1.0) * 0.5; let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0); - // Page-wide headers and footers sharing this margin aren't part of the local column. - let neighbour = blocks + // The column an overlapping paragraph makes, when it holds the row. + let column = |other: &TextBlock| -> Option<[f64; 2]> { + let left = other.rect[0]; + let right = other + .lines + .iter() + .filter_map(|i| lines.get(*i)) + .filter(|line| (line.rect[0] - left).abs() < tolerance) + .map(|line| line.rect[2]) + .fold(f64::NEG_INFINITY, f64::max); + (right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right]) + }; + let frame = blocks .iter() .filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1]) - .min_by(|a, b| gap(a).total_cmp(&gap(b))); - let Some(neighbour) = neighbour else { return Some(own) }; - let left = neighbour.rect[0]; - let right = neighbour - .lines - .iter() - .filter_map(|i| lines.get(*i)) - .filter(|line| (line.rect[0] - left).abs() < tolerance) - .map(|line| line.rect[2]) - .fold(f64::NEG_INFINITY, f64::max); - let holds = right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance; - Some(if holds { [left, right] } else { own }) + .filter_map(|other| column(other).map(|frame| (frame, gap(other)))) + .min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b))) + .map(|(frame, _)| frame); + Some(frame.unwrap_or(own)) } /// Whether a block reads as centred, and so must keep its centre when it grows or wraps. From 648dd42810e4858aa313950c63abc5bf26252d44 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:12:11 +1100 Subject: [PATCH 29/82] fix(edit): a column is measured by its whole rows; an isolated row keeps its width Two holes in alignment_frame: - A column's right edge came from the runs that start at its left edge, so a row of several styles ended at its first run, and the column could fail to hold a title wider than that run. It now takes the right edge of each complete row (row_rect). - A row with no column to align in was framed by its own box, which the rewrite also used as the width its text wraps at: changing an isolated row's text together with an alignment wrapped it at its old width, word by word. When the text changes and no column holds the row, alignment_frame now returns None, so the row keeps the width it has without the alignment. With the text unchanged the own box is still the frame, so aligning twice is still a no-op. --- crates/edit/src/tests.rs | 35 ++++++++++++++++++++++++ crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/grouping.rs | 29 ++++++++++++++------ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 48df3e4395..7691039f24 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7255,6 +7255,41 @@ fn a_justified_paragraph_from_the_editor_fills_its_rows() { assert!(row("end")[2] < right - 20.0, "the last row isn't"); } +/// A column whose rows are several runs (a second style on the same baseline) reaches the right +/// edge of its whole rows, not of the run at the left edge: a title wider than that first run is +/// still held by the column and aligns to it (CodeRabbit on b9940d7d). +#[test] +fn a_column_is_measured_by_its_whole_rows_not_its_first_runs() { + let page = "BT /F1 18 Tf 1 0 0 1 100 672 Tm (A longer title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj ET BT /F1 11 Tf 1 0 0 rg 1 0 0 1 185 610 Tm (and keeps going on.) Tj ET BT /F1 10 Tf 12 TL 50 598 Td (A second row of it.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let body = blocks.iter().find(|b| b.text.starts_with("The first")).expect("the body is a block"); + assert!(body.rows.first().is_some_and(|row| row.len() >= 2), "the body's first row is several runs: {:?}", body.rows); + let lines = text::text_lines(&text_page(page), 0).unwrap(); + let column_right = + body.rows.iter().filter_map(|row| row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect[2]).reduce(f64::max)).fold(f64::MIN, f64::max); + let mut doc = text_page(page); + let index = blocks.iter().position(|b| b.text == "A longer title").expect("the title is a block of its own"); + let style = text::BlockStyle { aligns: vec![Some(added::Align::Right)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("A longer title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "A longer title").unwrap().rect; + assert!((title[2] - column_right).abs() < 1.0, "right: on the whole row's right edge, {title:?} vs {column_right}"); +} + +/// A row with no column of its own is aligned in its own box, but that box is not the width its new +/// text wraps at: changing the text together with an alignment keeps a single row on one row, as +/// it does without the alignment (CodeRabbit on b9940d7d). +#[test] +fn an_isolated_row_edited_with_an_alignment_does_not_wrap_at_its_old_width() { + for align in [added::Align::Center, added::Align::Right] { + let mut doc = text_page("BT /F1 18 Tf 1 0 0 1 100 700 Tm (Title) Tj ET"); + let longer = "A much longer title for the page"; + let style = text::BlockStyle { aligns: vec![Some(align)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(longer), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [longer], "{align:?}: the title stays on one row"); + } +} + /// A heading across the whole page sits closer to the title than the title's own column does: the /// title is still aligned in its own column, not across the heading's page-wide frame (FredApps's /// review of 43f848d5). Centre and right both go by the column. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 4d4e16d669..3f436d7b24 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -766,7 +766,7 @@ pub(crate) fn rewrite_block_from( // An alignment for a one-row paragraph is laid out across the column it sits in, as a // paragraph of that width moved to its left edge. let framed; - let style = match alignment_frame(&lines, &blocks, &b, style) { + let style = match alignment_frame(&lines, &blocks, &b, style, text.is_some_and(|t| t != b.editor_text)) { Some([left, right]) => { framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; &framed diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 2e44290117..3a16e4f7c0 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -49,9 +49,15 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { /// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame /// to align in: left-aligning a title centred on the page left it where it was, and since a single /// row grows to the page's margin, centring it again moved it right each time. `None` when no -/// alignment is asked for, or when the paragraph is several rows, turned, or given a width or a -/// move of its own. -pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle) -> Option<[f64; 2]> { +/// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of +/// its own, or when its text changes and no column holds it (`text_changed`). +pub(crate) fn alignment_frame( + lines: &[TextLine], + blocks: &[TextBlock], + block: &TextBlock, + style: &BlockStyle, + text_changed: bool, +) -> Option<[f64; 2]> { let asked = style.align.is_some() || style.aligns.iter().any(Option::is_some); if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { return None; @@ -62,12 +68,13 @@ pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: & // The column an overlapping paragraph makes, when it holds the row. let column = |other: &TextBlock| -> Option<[f64; 2]> { let left = other.rect[0]; + // Whole rows, not their runs: a row of several styles reaches its last run's right edge. let right = other - .lines + .rows .iter() - .filter_map(|i| lines.get(*i)) - .filter(|line| (line.rect[0] - left).abs() < tolerance) - .map(|line| line.rect[2]) + .filter_map(|row| row_rect(row, lines)) + .filter(|row| (row[0] - left).abs() < tolerance) + .map(|row| row[2]) .fold(f64::NEG_INFINITY, f64::max); (right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right]) }; @@ -77,7 +84,13 @@ pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: & .filter_map(|other| column(other).map(|frame| (frame, gap(other)))) .min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b))) .map(|(frame, _)| frame); - Some(frame.unwrap_or(own)) + // The row's own box, with no column to align in, is no width for new text to wrap at: edited, + // the row keeps the width it would have had without the alignment. + match frame { + Some(frame) => Some(frame), + None if text_changed => None, + None => Some(own), + } } /// Whether a block reads as centred, and so must keep its centre when it grows or wraps. From e228e0edfc0d14c8f9bf60fbe94d001ac41c6c56 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:58:15 +0200 Subject: [PATCH 30/82] M7: keep a blank line as a paragraph break (#2517) A gap of 2.4 times the font size was still under the 2.5 times join limit, so two paragraphs became one and editing the first deleted the second. Once a paragraph has two rows, a wider gap than its own leading stays the next paragraph. --- ROADMAP.md | 1 + crates/automation/tests/it/automation.rs | 30 ++++++++++++++++++++++++ crates/edit/src/tests.rs | 28 ++++++++++++++++++++++ crates/edit/src/text_regions/grouping.rs | 18 +++++++++++++- docs/gaps.md | 5 +++- docs/roadmap.md | 3 ++- parity/acrobat-features.toml | 4 ++-- 7 files changed, 84 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..19bad63491 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -223,6 +223,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. Rows join while the gap is within the paragraph's own leading, or within about twice the font size when the paragraph has only one row. Editing the first paragraph leaves the next one in place, including after the first paragraph grows by a row. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. - **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..202f204628 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9457,3 +9457,33 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]); } + +/// #2517, through the same tools as the report: one blank line is two paragraphs, and editing +/// the first leaves the second. Growing the first by a row does not absorb a neighbour that +/// started three sizes below. +#[test] +fn a_blank_line_stays_a_separate_paragraph_through_tools() { + let dir = workdir("blank-line"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(before["count"], 2, "{before}"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + let second = after["paragraphs"][1]["text"].as_str().unwrap(); + assert!(second.contains("Second paragraph"), "{second}"); + + let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET"; + std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap(); + let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2); + ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 359155015f..bb752149d4 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2790,6 +2790,34 @@ fn a_bold_stand_in_font_leaves_an_earlier_regular_one_alone() { assert_eq!(font_of("TENSÃO").as_deref(), Some("Times-Bold"), "{lines:?}"); } +/// #2517: one blank line at ordinary leading (2 × 1.2 × the font size) is a paragraph break. +/// Editing the first paragraph must leave the second one in place. +#[test] +fn a_blank_line_keeps_the_next_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(before.len(), 2, "{:?}", before.iter().map(|b| b.text.as_str()).collect::>()); + text::replace_block(&mut doc, 0, 0, "First paragraph, edited row.\nFirst paragraph, second row.").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text); +} + +/// #2517: adding a row must not pull in a paragraph that was 3 × the font size below. +#[test] +fn a_grown_paragraph_does_not_swallow_the_next_one() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text); +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index b8a5abca1e..9d3e5eaa9d 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -253,6 +253,22 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol || hanging; + // Once a paragraph has two rows, its own leading is known. A blank line is about + // twice that, so a wider gap is the next paragraph (#2517). A single row has no + // leading yet: ordinary leading (about 1.2 × the size) and double spacing (2 × the + // size) still join, and a blank line (2.4 × the size) does not. The old cap of + // 2.5 × the size joined the blank line too. + let own_leading = block.rows.len().checked_sub(2).and_then(|i| { + let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n)); + let below = prev_row.first().and_then(|n| lines.get(*n)); + match (above, below) { + (Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()), + _ => None, + } + }); + // 2.05 keeps a double-spaced row (gap == 2 × size) in the paragraph and still + // rejects a blank line, which is 2.4 × the size. + let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).map_or(tol * 2.05, |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) @@ -262,7 +278,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { && aligned && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 && gap > first.size.min(prev.size) * 0.8 - && gap < first.size.max(prev.size) * 2.5 + && gap < max_gap }); if !joins { blocks.push(TextBlock { diff --git a/docs/gaps.md b/docs/gaps.md index 56ec3ae748..e74daadbee 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,7 @@ # Where PdfCraft falls short of Acrobat Pro > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: #2517 a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -100,7 +101,8 @@ update the numbers. Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same - baseline is still replaced. Estimates are unchanged. + baseline is still replaced. A blank line at ordinary leading stays a paragraph break, so editing + the first paragraph leaves the next one in place (#2517). Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping @@ -404,6 +406,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | +| 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..d90c65a67c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -265,6 +265,7 @@ Estimates unchanged. | 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | +| 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | | 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..3bed5a6db3 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517); hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" [[feature]] id = "edit.font-reuse-substitution" From 53e6d832af37732b87c6b2650152344d53c32f6f Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:43:49 +0200 Subject: [PATCH 31/82] M7: keep an explicit leading, and a grown paragraph's neighbour (#2517) The join limit follows the text object's TL when it has one, checked against the measured baseline gap. A rewrite of every row is marked so a new row one leading above the next paragraph does not absorb it. --- ROADMAP.md | 2 +- crates/automation/tests/it/automation.rs | 22 +++++-- crates/edit/src/tests.rs | 81 ++++++++++++++++++++++++ crates/edit/src/text_regions/grouping.rs | 16 ++--- crates/edit/src/text_regions/styled.rs | 11 ++++ docs/gaps.md | 7 +- parity/acrobat-features.toml | 2 +- 7 files changed, 124 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 19bad63491..f96de024f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -204,6 +204,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is checked against the measured baseline gap: rows at that gap stay one paragraph, and a gap of twice the `TL` does not. Growing the paragraph so the new row is one leading above the next paragraph does not absorb it on a later edit. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. @@ -223,7 +224,6 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. -- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. Rows join while the gap is within the paragraph's own leading, or within about twice the font size when the paragraph has only one row. Editing the first paragraph leaves the next one in place, including after the first paragraph grows by a row. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. - **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 202f204628..61d9a4deb3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9458,9 +9458,10 @@ fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]); } -/// #2517, through the same tools as the report: one blank line is two paragraphs, and editing -/// the first leaves the second. Growing the first by a row does not absorb a neighbour that -/// started three sizes below. +/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing +/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt +/// leadings under the second row). Growing the first paragraph, saving, reopening and editing +/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph. #[test] fn a_blank_line_stays_a_separate_paragraph_through_tools() { let dir = workdir("blank-line"); @@ -9478,12 +9479,23 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() { assert!(second.contains("Second paragraph"), "{second}"); let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ - BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET"; + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET"; std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap(); let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2); ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" })); - let after = ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 })); + ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); assert_eq!(after["count"], 2, "{after}"); assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + + let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"; + std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap(); + let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index bb752149d4..12f8eebe6d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2802,7 +2802,17 @@ fn a_blank_line_keeps_the_next_paragraph() { text::replace_block(&mut doc, 0, 0, "First paragraph, edited row.\nFirst paragraph, second row.").unwrap(); let after = text::text_blocks(&doc, 0).unwrap(); assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[0].text.contains("edited row") && after[0].text.contains("second row"), "{:?}", after[0].text); assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text); + // The second row is left in the source stream. A marker around only the rewritten row + // would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph. + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + if let Some(start) = content.find("/P BMC") { + let end = start + content[start..].find("EMC").unwrap_or(content.len() - start); + let marked = &content[start..end]; + assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}"); + assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}"); + } } /// #2517: adding a row must not pull in a paragraph that was 3 × the font size below. @@ -2818,6 +2828,77 @@ fn a_grown_paragraph_does_not_swallow_the_next_one() { assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text); } +/// An explicit `TL` is the leading `T*` moves by. Rows at that gap are one paragraph, including +/// when the gap is larger than twice the font size. A following paragraph that starts two +/// leadings lower stays separate. The numbers are the gaps under test, not a claim about how a +/// word processor derives them: this repo's Helvetica box is 0.925 em (ascent 0.718, descent +/// 0.207), so twice that box at 12 pt is 22.2 pt, while 27.6 pt is 12 × 2.3. +#[test] +fn explicit_leading_is_the_measured_baseline_gap() { + for tl in [14.4_f64, 18.0, 22.2, 24.0, 27.6, 28.8] { + let src = format!("BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"); + let doc = text_page(&src); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - tl).abs() < 0.05), "TL {tl} did not move the baselines: {gaps:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "TL {tl}, measured gaps {gaps:?}: {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows at the TL, then a paragraph whose first baseline is two TLs below the second row. + let tl = 14.4_f64; + let next = 700.0 - tl - 2.0 * tl; + let blank = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {next} Tm (Neighbour) Tj ET" + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let between = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((between - 2.0 * tl).abs() < 0.05, "the blank line measured {between}, not {}", 2.0 * tl); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The neighbour starts one blank line below (two leadings under the second row). Adding a row +/// puts the new baseline one leading above that neighbour, inside the join window. The neighbour +/// still has to survive the edit, both saves, and a second edit. +#[test] +fn growing_into_a_blank_line_keeps_the_neighbour_after_save() { + let tl = 14.4_f64; + let src = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {} Tm (Neighbour) Tj ET", + 700.0 - 3.0 * tl + ); + let mut doc = text_page(&src); + let before = text::text_lines(&doc, 0).unwrap(); + let opening = before[1].origin_baseline() - before[2].origin_baseline(); + assert!((opening - 2.0 * tl).abs() < 0.05, "fixture gap {opening} is not a blank line"); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let bases: Vec = lines.iter().map(|l| l.origin_baseline()).collect(); + assert_eq!(bases.len(), 4, "{bases:?}"); + let grown = bases[2] - bases[3]; + assert!((grown - tl).abs() < 0.05, "the new row is {grown} pt above the neighbour, not one leading"); + assert!(grown < tl * 1.5, "this gap is outside the join window, so the test would pass without the marker"); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let marked = content.find("/P BMC").and_then(|start| content[start..].find("EMC").map(|end| content[start..start + end].to_owned())); + let marked = marked.unwrap_or_else(|| panic!("a full rewrite did not mark its rows: {content}")); + for row in ["(One)", "(Two)", "(Three)"] { + assert!(marked.contains(row), "{row} is outside the marker: {content}"); + } + assert!(!marked.contains("Neighbour"), "the neighbour was pulled into the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["One Two Three", "Neighbour"]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "One\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["One Two Four", "Neighbour"]); + } +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 9d3e5eaa9d..0f2eb7989b 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -253,11 +253,12 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol || hanging; - // Once a paragraph has two rows, its own leading is known. A blank line is about - // twice that, so a wider gap is the next paragraph (#2517). A single row has no - // leading yet: ordinary leading (about 1.2 × the size) and double spacing (2 × the - // size) still join, and a blank line (2.4 × the size) does not. The old cap of - // 2.5 × the size joined the blank line too. + // Once a paragraph has two rows, its own leading is the measured baseline gap. A + // blank line is about twice that, so a wider gap is the next paragraph (#2517). A + // single row uses the text object's TL when it has one: a gap equal to that TL + // joins, and a gap of twice the TL does not. Without a TL, ordinary leading still + // joins and a gap of 2.4 × the size does not. The old cap of 2.5 × the size joined + // the blank line too. let own_leading = block.rows.len().checked_sub(2).and_then(|i| { let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n)); let below = prev_row.first().and_then(|n| lines.get(*n)); @@ -266,9 +267,8 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { _ => None, } }); - // 2.05 keeps a double-spaced row (gap == 2 × size) in the paragraph and still - // rejects a blank line, which is 2.4 × the size. - let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).map_or(tol * 2.05, |lead| lead * 1.5); + let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4); + let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).map_or(tol * 2.05, |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e8b3b683d0..121adc171f 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -796,6 +796,14 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + // A new row can land one leading above the next paragraph and look like the same + // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one + // scope so the neighbour stays out (#2517). A row left in the source stream cannot share + // that marker, so it is omitted and geometry groups those rows as before. + let marked = kept.iter().all(|row| !*row); + if marked { + block_ops.push(Op::new("BMC", vec![Object::name("P")])); + } let crop = p.crop(doc); let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); let source_leading = block @@ -966,6 +974,9 @@ pub(crate) fn rewrite_styled_block( style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) }; } + if marked { + block_ops.push(Op::new("EMC", vec![])); + } block_ops.push(Op::new("Q", vec![])); block_ops.extend(after.ops()); if split { diff --git a/docs/gaps.md b/docs/gaps.md index e74daadbee..75b0007e27 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -101,8 +101,11 @@ update the numbers. Legacy non-SFNT previews, mixed-font/style contextual runs, structure-tree fidelity and reliable semantic grouping on every untagged PDF remain open. Replacing one line or paragraph no longer removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same - baseline is still replaced. A blank line at ordinary leading stays a paragraph break, so editing - the first paragraph leaves the next one in place (#2517). Estimates are unchanged. + baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the + measured baseline gap, and growing a paragraph so its new row is one leading above the next + one does not absorb that paragraph (#2517). A heading and smaller body lines (#1756), a + hanging-indent continuation (#2564), and cells in different columns (#1680, #2478) are still + grouped by the same geometry. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3bed5a6db3..f14ce86754 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1915,7 +1915,7 @@ status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517); hyphenated line ends are joined; a row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it" +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading: the measured baseline gap equals it, a gap of that size stays one paragraph even above twice the font size, and a gap of twice the TL is the next paragraph. A rewrite of every row is marked, so a new row one leading above the next paragraph does not absorb it on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478)" [[feature]] id = "edit.font-reuse-substitution" From 4ebb575a886c37390afbf7c3bc1446a4abe5f033 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:16:43 +0200 Subject: [PATCH 32/82] M7: keep a repeated step without TL, and a partial rewrite's neighbour (#2517) Three or more rows at one repeated step stay one paragraph when the file has no TL, which is how LibreOffice writes each line. A rewrite that leaves a source row still marks the whole paragraph, so the neighbour survives a second edit. Dragging the box width keeps the line breaks. --- ROADMAP.md | 2 +- crates/automation/tests/it/automation.rs | 80 ++++ crates/edit/src/tests.rs | 170 ++++++++ crates/edit/src/text_regions/grouping.rs | 31 +- crates/edit/src/text_regions/styled.rs | 37 +- crates/ui-egui/src/tools/edit_text_ui.rs | 4 +- crates/ui-egui/tests/it/editing.rs | 222 +++++++++++ crates/ui-egui/tests/play.rs | 374 ++++++++++++++++++ .../ui-egui/tests/play/blank-line-edit.json | 17 + .../play/partial-rewrite-keeps-neighbor.json | 18 + .../play/repeated-step-opens-as-one.json | 11 + .../play/repeated-step-td-inside-one-bt.json | 9 + .../ui-egui/tests/play/repeated-step-tm.json | 9 + .../tests/play/resize-grown-neighbor.json | 19 + .../ui-egui/tests/play/widen-example-one.json | 19 + parity/acrobat-features.toml | 4 +- 16 files changed, 1013 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/play.rs create mode 100644 crates/ui-egui/tests/play/blank-line-edit.json create mode 100644 crates/ui-egui/tests/play/partial-rewrite-keeps-neighbor.json create mode 100644 crates/ui-egui/tests/play/repeated-step-opens-as-one.json create mode 100644 crates/ui-egui/tests/play/repeated-step-td-inside-one-bt.json create mode 100644 crates/ui-egui/tests/play/repeated-step-tm.json create mode 100644 crates/ui-egui/tests/play/resize-grown-neighbor.json create mode 100644 crates/ui-egui/tests/play/widen-example-one.json diff --git a/ROADMAP.md b/ROADMAP.md index f96de024f2..de6d8e9692 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -204,7 +204,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. -- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is checked against the measured baseline gap: rows at that gap stay one paragraph, and a gap of twice the `TL` does not. Growing the paragraph so the new row is one leading above the next paragraph does not absorb it on a later edit. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 61d9a4deb3..2f000fb0c6 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9499,3 +9499,83 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() { let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); } + +fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().to_string()) + .collect() +} + +/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract. +/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with +/// Td, a two-row file that must stay split, and a partial rewrite that adds a row. +#[test] +fn paragraph_grouping_survives_save_and_reopen_through_tools() { + let dir = workdir("paragraph-grouping"); + let mut a = auto(&dir); + for step in [27.6_f64, 28.8] { + let y2 = 700.0 - step; + let y3 = 700.0 - 2.0 * step; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET" + ), + format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"), + ]; + for (i, body) in bodies.iter().enumerate() { + let path = format!("step-{step}-{i}.pdf"); + std::fs::write(dir.join(&path), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let paras = paragraph_texts(&mut a, doc); + assert_eq!(paras.len(), 1, "{path}: {paras:?}"); + assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}"); + let extracted = page_text(&mut a, doc); + assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}"); + } + } + + // One gap and no third row: still two paragraphs. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6); + std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap(); + let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2); + + // Blank line written with Td, not TL. The inner step repeats; the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap(); + let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap(); + let blank_paras = paragraph_texts(&mut a, blank_doc); + assert_eq!(blank_paras.len(), 2, "{blank_paras:?}"); + assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}"); + assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}"); + + // The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen. + let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}"); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 12f8eebe6d..2d17252bc5 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2899,6 +2899,176 @@ fn growing_into_a_blank_line_keeps_the_neighbour_after_save() { } } +/// No `TL`: `Td` does not set the leading (`TD` does). Three rows at one repeated step are one +/// paragraph. A single large gap, with no third row at that step, stays a break. +#[test] +fn a_repeated_step_without_tl_is_the_paragraph_leading() { + let step = 27.6_f64; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + ]; + for body in bodies { + let doc = text_page(&body); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - step).abs() < 0.05), "step {step} measured {gaps:?} in {body}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{body} -> {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows and nothing after them: one gap cannot be told from a paragraph break. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - step); + assert_eq!(text::text_blocks(&text_page(&pair), 0).unwrap().len(), 2); + // The original blank line, written with Td instead of TL: the inner step repeats, the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let break_gap = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((break_gap - 2.0 * inner).abs() < 0.05, "blank line measured {break_gap}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The first row is unchanged, so the rewrite leaves it in the source stream and skips the +/// paragraph marker. The added row then sits one leading above the neighbour. +#[test] +fn a_partial_rewrite_that_adds_a_row_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + +/// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. +/// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. +#[test] +fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha paragraph, first row of text.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 650 Tm (Bravo paragraph, a single row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 600 Tm (Charlie paragraph, first row of text.) Tj T* (Charlie paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let names = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(names(&doc).len(), 3, "{:?}", names(&doc)); + text::replace_block(&mut doc, 0, 0, "Alpha paragraph, first row of text.\nAlpha paragraph, second row.\nAlpha paragraph, a third row.").unwrap(); + let after = names(&doc); + assert!(after.iter().any(|t| t.contains("Bravo") && !t.contains("Alpha")), "Bravo was absorbed: {after:?}"); + assert!(after.iter().any(|t| t.contains("Charlie")), "Charlie is gone: {after:?}"); + let bravo = after.iter().position(|t| t.contains("Bravo")).unwrap(); + text::replace_block(&mut doc, 0, bravo, "Bravo paragraph, edited.").unwrap(); + let done = names(&doc); + assert!(done.iter().any(|t| t.contains("Charlie")), "editing Bravo removed Charlie: {done:?}"); + assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}"); +} + +/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the +/// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate +/// drawing and delete it. +#[test] +fn resizing_a_grown_paragraph_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row."; + text::replace_block(&mut doc, 0, 0, text).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let added = lines.iter().find(|l| l.text.contains("Alpha next row.")).unwrap(); + let neighbour = lines.iter().find(|l| l.text.contains("Neighbor")).unwrap(); + assert!((added.origin_baseline() - neighbour.origin_baseline()).abs() < 0.5, "the new row is not on the neighbour"); + let width = text::text_blocks(&doc, 0).unwrap()[0].rect[2] - text::text_blocks(&doc, 0).unwrap()[0].rect[0]; + for factor in [0.6_f64, 1.6] { + let mut copy = doc.clone(); + text::rewrite_block(&mut copy, 0, 0, Some(text), &text::BlockStyle { width: Some((width * factor).max(40.0)), ..Default::default() }) + .unwrap(); + let texts = text::text_blocks(©, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert!(texts.iter().any(|t| t.contains("Neighbor stays.")), "width ×{factor} removed the neighbour: {texts:?}"); + } +} + +/// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the +/// heading above it and both paragraphs under example 2 as they were. +fn letter_page() -> &'static str { + "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \ + BT /F1 12 Tf 72 700 Td (One) Tj ET \ + BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \ + BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \ + BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \ + BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET" +} + +fn block_texts(doc: &Document) -> Vec { + text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +#[test] +fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph() { + let mut doc = text_page(letter_page()); + let before = block_texts(&doc); + assert!(before.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "{before:?}"); + assert!(before.iter().any(|t| t.contains("One") && t.contains("Three") && !t.contains("Repeated")), "{before:?}"); + assert!(before.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")), "{before:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("One") && b.text.contains("Three")).unwrap(); + // What the editor committed, including the line breaks. A later width change must keep them. + text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap(); + let shown = blocks[idx].editor_text.clone(); + let width = blocks[idx].rect[2] - blocks[idx].rect[0]; + text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + let kept = text::text_blocks(&doc, 0).unwrap().into_iter().find(|b| b.editor_text.contains("four")).unwrap(); + assert_eq!(kept.editor_text, "One\nTwo\nThree\nfour", "a wider box flattened the line breaks: {:?}", kept.editor_text); + assert!(after.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the heading joined the resized box: {after:?}"); + assert!( + after.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")), + "widening example 1 split the next paragraph: {after:?}" + ); + assert!(after.iter().any(|t| t.contains("Second paragraph, first row") && t.contains("second row")), "{after:?}"); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "{after:?}"); +} + +#[test] +fn a_row_added_one_leading_above_the_neighbour_does_not_absorb_it() { + let mut doc = text_page(letter_page()); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("Alpha first row")).unwrap(); + text::replace_block(&mut doc, 0, idx, "Alpha first row.\ntest\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "the added row absorbed the neighbour: {after:?}"); +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 0f2eb7989b..68a83569a5 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -227,9 +227,10 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { } } let mut blocks: Vec = Vec::new(); - for row in rows { + for row_at in 0..rows.len() { + let Some(row) = rows.get(row_at) else { continue }; let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; - let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; + let (Some(rect), Some(frame)) = (row_rect(row, lines), frame_row_rect(row, lines)) else { continue }; let joins = blocks.last().is_some_and(|block| { // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and // letting it join is what made an edit of the row above delete this item and the ones @@ -256,9 +257,10 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { // Once a paragraph has two rows, its own leading is the measured baseline gap. A // blank line is about twice that, so a wider gap is the next paragraph (#2517). A // single row uses the text object's TL when it has one: a gap equal to that TL - // joins, and a gap of twice the TL does not. Without a TL, ordinary leading still - // joins and a gap of 2.4 × the size does not. The old cap of 2.5 × the size joined - // the blank line too. + // joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A + // step that repeats on the following row is that leading; one large gap, with no + // second step to compare, stays a break. The old cap of 2.5 × the size joined the + // blank line too. let own_leading = block.rows.len().checked_sub(2).and_then(|i| { let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n)); let below = prev_row.first().and_then(|n| lines.get(*n)); @@ -268,7 +270,20 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { } }); let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4); - let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).map_or(tol * 2.05, |lead| lead * 1.5); + // The next row is not in this block yet. Its gap is only a leading when it matches + // this one: a blank line is a single wider gap and has no matching step after it. + let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let nframe = frame_row_rect(next, lines)?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + let same = same_plane(first, nfirst) + && (mixed || same_style(first, nfirst)) + && first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4 + && (nframe[0] - frame[0]).abs() < tol; + let scale = gap.abs().max(ngap.abs()); + if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None } + }); + let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) @@ -302,7 +317,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { if !block.rows.is_empty() { block.editor_text.push('\n'); } - for &index in &row { + for &index in row { let Some(line) = lines.get(index) else { continue }; // Some producers position separate words without encoding a space. if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) @@ -329,7 +344,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { } block.text.push_str(row_text.trim_matches(paragraph_separator)); block.rect = union(block.rect, rect); - block.rows.push(row); + block.rows.push(row.clone()); } blocks } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 121adc171f..a9f3711072 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -609,6 +609,22 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +/// Operator index of the paragraph's first text object, and of the next line's text object +/// when one follows. The next index is where a closing marker belongs. +fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option)> { + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); + let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?; + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let next = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op)) + .min(); + Some((start, next)) +} + struct RewriteAnchor<'a> { lead: &'a TextLine, drop: std::collections::HashSet, @@ -985,7 +1001,26 @@ pub(crate) fn rewrite_styled_block( block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } let at = if split { start } else { o.bt_op }; - let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); + // A kept source row sits outside `block_ops`, so a marker only around the rewrite splits + // the paragraph or leaves the added row to join the neighbour. Bound the whole paragraph: + // from its first text object up to, but not including, the next line. + let marker = if marked { None } else { paragraph_span(block, lines) }; + if marker.is_some_and(|(_, next)| next.is_none()) { + block_ops.push(Op::new("EMC", vec![])); + } + let data = splice(&pieces, &ops, |i| { + let mut insert = Vec::new(); + if marker.is_some_and(|(start_at, _)| i == start_at) { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if i == at { + insert.append(&mut block_ops); + } + if marker.is_some_and(|(_, next)| next == Some(i)) { + insert.push(Op::new("EMC", vec![])); + } + (insert, !drop.contains(&i)) + }); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 224bc3f545..c932ed3608 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -399,7 +399,9 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); let shift = to[0] - from[0]; let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; - Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style } + // `text` joins rows with spaces. The editor text is what the paragraph actually + // is, line breaks included; a width change must not throw those breaks away. + Edit::EditTextBlock { page, block: d.block, text: l.editor_text.clone(), style } } else { // Translation must keep the selected source operators. Rewriting unchanged text // would also remove unrelated paragraphs that now overlap this one (#594). diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0d44f91067..5a124aa390 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3668,6 +3668,228 @@ fn a_paragraph_editor_opens_at_full_opacity() { } } +/// One US Letter page of Helvetica, for driving the live editor on a known baseline. +fn letter_text(body: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\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 offset in offsets { + out.extend_from_slice(format!("{offset: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 +} + +fn open_letter(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes(name, None, bytes).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn click_page(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { + let page = h.state().views[0].page_screen_rect(0).expect("the page is on screen"); + let at = egui::pos2(page.left() + x / 612.0 * page.width(), page.top() + (792.0 - y) / 792.0 * page.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); +} + +fn save_live_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + for _ in 0..40 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + } + let dir = std::path::Path::new("/tmp/pdfcraft-visual"); + let _ = std::fs::create_dir_all(dir); + if let Ok(image) = h.render() { + let _ = image.save(dir.join(name)); + } +} + +/// Live editor: a repeated step with no TL opens as one paragraph, and adding a row +/// beside a kept first row leaves the neighbour as its own paragraph on screen. +#[test] +fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() { + let step = 27.6_f32; + let repeated = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ); + let mut h = open_letter("repeated.pdf", letter_text(&repeated)); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + click_page(&mut h, 90.0, 700.0 - step + 4.0); + let editor = h.state().views[0].line_editor.clone().expect("the middle row opens"); + assert!(editor.text.contains("One") && editor.text.contains("Two") && editor.text.contains("Three"), "{}", editor.text); + save_live_shot(&mut h, "live-repeated-step.png"); + + let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + let mut h = open_letter("partial.pdf", letter_text(partial)); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + click_page(&mut h, 120.0, 704.0); + let editor = h.state().views[0].line_editor.clone().expect("the first paragraph opens"); + assert!(editor.text.contains("Alpha first row.") && editor.text.contains("Alpha next row."), "{}", editor.text); + assert!(!editor.text.contains("Neighbor"), "{}", editor.text); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nTwo\nThree".into(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(6); + let blocks = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + doc.text_blocks(0).iter().map(|b| b.text.clone()).collect::>() + }; + assert_eq!(blocks, ["Alpha first row. Two Three", "Neighbor stays."]); + save_live_shot(&mut h, "live-after-adding-a-row.png"); + click_page(&mut h, 120.0, 660.0); + let neighbour = h.state().views[0].line_editor.clone().expect("the neighbour opens on its own"); + assert_eq!(neighbour.text, "Neighbor stays."); + save_live_shot(&mut h, "live-neighbour-open.png"); +} + +/// Widening example 1 must not pull the heading into that box or split the paragraph under example 2. +#[test] +fn widening_example_one_keeps_the_heading_and_the_next_paragraph() { + let body = "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \ + BT /F1 12 Tf 72 700 Td (One) Tj ET \ + BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \ + BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \ + BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \ + BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; + let mut h = open_letter("letter.pdf", letter_text(&body)); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + click_page(&mut h, 90.0, 690.0); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "One\nTwo\nThree\nfour".into(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(8); + let (edge, page_id) = { + let s = h.state(); + let id = s.views[0].id; + let blocks = s.session.get(id).unwrap().text_blocks(0); + let texts: Vec<_> = blocks.iter().map(|b| b.text.clone()).collect(); + assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "commit already joined the heading: {texts:?}"); + assert!( + texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")), + "commit already split the next paragraph: {texts:?}" + ); + let block = blocks.iter().find(|b| b.text.contains("four")).expect("the edited paragraph"); + let page = s.views[0].page_screen_rect(0).unwrap(); + let edge = egui::pos2( + page.left() + block.rect[2] as f32 / 612.0 * page.width(), + page.top() + (792.0 - (block.rect[1] + block.rect[3]) as f32 / 2.0) / 792.0 * page.height(), + ); + (edge, id) + }; + let to = edge + egui::vec2(280.0, 0.0); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(edge + (to - edge) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(8); + let texts: Vec<_> = { + let s = h.state(); + s.session.get(page_id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect() + }; + let edited = { + let s = h.state(); + s.session + .get(page_id) + .unwrap() + .text_blocks(0) + .iter() + .find(|b| b.editor_text.contains("four")) + .expect("the widened paragraph") + .editor_text + .clone() + }; + assert_eq!(edited, "One\nTwo\nThree\nfour", "the width handle flattened the line breaks: {edited:?}"); + assert!(texts.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the width handle joined the heading: {texts:?}"); + assert!( + texts.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")), + "the width handle split the next paragraph: {texts:?}" + ); + save_live_shot(&mut h, "live-example-one-widened.png"); +} + +/// The live width handle, after a row was added on top of the neighbour. +#[test] +fn resizing_the_live_box_keeps_the_neighbour() { + let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; + let mut h = open_letter("partial.pdf", letter_text(partial)); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + click_page(&mut h, 120.0, 414.0); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row.".into(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::Enter); + h.run_steps(6); + let before = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::>() + }; + assert!(before.iter().any(|t| t.contains("Neighbor stays.")), "the edit already dropped it: {before:?}"); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let rect = h.state().session.get(h.state().views[0].id).unwrap().text_blocks(0)[0].rect; + let edge = egui::pos2( + page.left() + rect[2] as f32 / 612.0 * page.width(), + page.top() + (792.0 - (rect[1] + rect[3]) as f32 / 2.0) / 792.0 * page.height(), + ); + let to = edge + egui::vec2(80.0, 0.0); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(edge + (to - edge) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(6); + let after = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::>() + }; + assert!(after.iter().any(|t| t.contains("Neighbor stays.")), "resize removed the neighbour: {after:?}"); + save_live_shot(&mut h, "live-after-resize.png"); +} + #[test] fn editing_existing_text_in_place() { let mut h = harness(1, |_| {}); diff --git a/crates/ui-egui/tests/play.rs b/crates/ui-egui/tests/play.rs new file mode 100644 index 0000000000..0cacc447a2 --- /dev/null +++ b/crates/ui-egui/tests/play.rs @@ -0,0 +1,374 @@ +//! Human sessions for the editor. +//! +//! Each file in `tests/play/*.json` is one session a person can do: open a page, click a +//! paragraph, type, confirm, drag a box edge, then click the paragraph that must still be +//! separate and read it back. Every session is repeated on several desks (window size and +//! zoom), because that is what changes where a click lands. +//! +//! A miss writes a screenshot and the paragraph list to `/tmp/pdfcraft-play/` and fails this +//! test with that report. `cargo test --workspace` runs it on the Linux and Windows CI +//! machines. The paragraph rules themselves live in `pdfcraft-edit` and run on every OS that +//! job tests. A pass covers these sessions and these desks, not every PDF a person might open. +//! +//! Page coordinates are PDF points. The default page is US Letter (612 by 792). +//! +//! ```text +//! cargo test -p pdfcraft-ui-egui --test play -- --nocapture +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct Session { + name: String, + content: String, + /// PDF page size in points. Omitted sessions use US Letter. + #[serde(default = "letter")] + page: [f32; 2], + /// Window sizes and zooms. Omitted sessions use the shared desks. + #[serde(default)] + desks: Vec, + steps: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct Desk { + width: f32, + height: f32, + /// Zoom percent, as `ui.set zoom` accepts it (`100` is 100%). + zoom: String, +} + +fn desks() -> Vec { + vec![ + Desk { width: 1100.0, height: 1400.0, zoom: "100".into() }, + Desk { width: 1280.0, height: 800.0, zoom: "60".into() }, + Desk { width: 900.0, height: 700.0, zoom: "50".into() }, + ] +} + +fn letter() -> [f32; 2] { + [612.0, 792.0] +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum Step { + Command { + id: String, + }, + ClickPage { + x: f32, + y: f32, + }, + /// Select everything in the open paragraph and type, including line breaks. + Type { + text: String, + }, + /// Replace the open paragraph's text without keystrokes. Prefer `type`. + Editor { + text: String, + }, + Key { + key: String, + #[serde(default)] + modifiers: Vec, + }, + DragEdge { + contains: String, + dx: f32, + #[serde(default)] + dy: f32, + }, + ExpectEditor { + contains: String, + text: String, + }, + ExpectParagraph { + text: String, + }, + ExpectTogether { + parts: Vec, + }, + ExpectApart { + text: String, + not: String, + }, + Shot { + name: String, + }, +} + +#[test] +fn play_sessions_fetch_paragraph_bugs() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/play"); + let mut files: Vec<_> = fs::read_dir(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())).map(|e| e.unwrap().path()).collect(); + files.retain(|p| p.extension().is_some_and(|x| x == "json")); + files.sort(); + assert!(!files.is_empty(), "no sessions in {}", dir.display()); + let mut bugs = Vec::new(); + for path in &files { + if let Err(report) = play_file(path) { + bugs.push(report); + } + } + assert!(bugs.is_empty(), "\n\n{}", bugs.join("\n\n")); +} + +fn play_file(path: &Path) -> Result<(), String> { + let raw = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let session: Session = serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?; + let desks = if session.desks.is_empty() { desks() } else { session.desks.clone() }; + let mut bugs = Vec::new(); + for desk in &desks { + if let Err(report) = play_desk(&session, desk) { + bugs.push(report); + } + } + if bugs.is_empty() { Ok(()) } else { Err(bugs.join("\n")) } +} + +fn play_desk(session: &Session, desk: &Desk) -> Result<(), String> { + let mut h = open_letter(&session.name, &session.content, desk); + if let Err(e) = h.state_mut().set_option("zoom", &desk.zoom) { + return Err(format!("{name} at {desk:?}: zoom: {e}", name = session.name)); + } + h.run_steps(4); + eprintln!("{name}: {width}×{height} at {zoom}%", name = session.name, width = desk.width, height = desk.height, zoom = desk.zoom); + for (n, step) in session.steps.iter().enumerate() { + if let Err(why) = step.run(&mut h, session) { + let shot = save_shot(&mut h, &format!("{}-{}x{}", session.name, desk.width, desk.height)); + let paras = paragraphs(&h); + return Err(format!( + "{name} on a {width}×{height} window at {zoom}% zoom: step {step_no} {step:?}\n{why}\nparagraphs:\n{paras}\nscreenshot: {shot}", + name = session.name, + width = desk.width, + height = desk.height, + zoom = desk.zoom, + step_no = n + 1, + paras = paras.iter().map(|p| format!("- {p}")).collect::>().join("\n"), + )); + } + } + Ok(()) +} + +impl Step { + fn run(&self, h: &mut Harness<'static, PdfCraftApp>, session: &Session) -> Result<(), String> { + match self { + Step::Command { id } => { + if !h.state_mut().execute(id) { + return Err(format!("command {id:?} did not run")); + } + h.run_steps(2); + } + Step::ClickPage { x, y } => click_page(h, session, *x, *y)?, + Step::Type { text } => type_into_paragraph(h, text)?, + Step::Editor { text } => { + let Some(editor) = h.state_mut().views.first_mut().and_then(|v| v.line_editor.as_mut()) else { + return Err("no paragraph is open for typing".into()); + }; + editor.text.clone_from(text); + h.run_steps(1); + } + Step::Key { key, modifiers } => { + let Some(key) = Key::from_name(key) else { return Err(format!("unknown key {key:?}")) }; + h.key_press_modifiers(modifiers_of(modifiers)?, key); + h.run_steps(8); + } + Step::DragEdge { contains, dx, dy } => drag_edge(h, session, contains, *dx, *dy)?, + Step::ExpectEditor { contains, text } => { + // The open editor is what a click just showed. Otherwise take the shortest + // paragraph that contains the words, so a heading that merely mentions them + // does not win. + let actual = if let Some(open) = h.state().views.first().and_then(|v| v.line_editor.as_ref()) { + open.text.clone() + } else { + let mut matches: Vec<_> = + paragraphs_full(h).into_iter().map(|p| p.editor_text).filter(|p| p.contains(contains.as_str())).collect(); + matches.sort_by_key(|p| p.len()); + let Some(found) = matches.into_iter().next() else { return Err(format!("no paragraph contains {contains:?}")) }; + found + }; + if !actual.contains(contains.as_str()) { + return Err(format!("the open paragraph is {actual:?}, which does not contain {contains:?}")); + } + if actual != *text { + return Err(format!("editor text\n actual: {actual:?}\n expect: {text:?}")); + } + } + Step::ExpectParagraph { text } => { + if !paragraphs(h).iter().any(|p| p.contains(text)) { + return Err(format!("no paragraph contains {text:?}")); + } + } + Step::ExpectTogether { parts } => { + if parts.is_empty() { + return Err("expect_together needs at least one part".into()); + } + if !paragraphs(h).iter().any(|p| parts.iter().all(|part| p.contains(part))) { + return Err(format!("these are not in the same paragraph: {parts:?}")); + } + } + Step::ExpectApart { text, not } => { + let paras = paragraphs(h); + if !paras.iter().any(|p| p.contains(text)) { + return Err(format!("no paragraph contains {text:?}")); + } + // A heading can mention both strings. The paragraph itself is one that has + // `text` and does not also have `not`. + if !paras.iter().any(|p| p.contains(text) && !p.contains(not)) { + return Err(format!("{text:?} was absorbed into a paragraph that also has {not:?}")); + } + } + Step::Shot { name } => { + let _ = save_named(&mut *h, name); + } + } + Ok(()) + } +} + +fn type_into_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str) -> Result<(), String> { + if h.state().views.first().and_then(|v| v.line_editor.as_ref()).is_none() { + return Err("no paragraph is open for typing".into()); + } + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.run_steps(1); + // One text event keeps the line breaks. The multiline editor inserts them. + h.event(egui::Event::Text(text.into())); + h.run_steps(2); + Ok(()) +} + +fn drag_edge(h: &mut Harness<'static, PdfCraftApp>, session: &Session, contains: &str, dx: f32, dy: f32) -> Result<(), String> { + let edge = { + let s = h.state(); + let Some(view) = s.views.first() else { return Err("no document is open".into()) }; + let Some(page) = view.page_screen_rect(0) else { return Err("the page is not on screen".into()) }; + let Some(doc) = s.session.get(view.id) else { return Err("the document is gone".into()) }; + let blocks = doc.text_blocks(0); + let Some(block) = blocks.iter().find(|b| b.editor_text.contains(contains) || b.text.contains(contains)) else { + return Err(format!("no paragraph contains {contains:?}")); + }; + let rect = block.rect; + let [pw, ph] = session.page; + egui::pos2(page.left() + rect[2] as f32 / pw * page.width(), page.top() + (ph - (rect[1] + rect[3]) as f32 / 2.0) / ph * page.height()) + }; + let to = edge + egui::vec2(dx, dy); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + for k in 1..=6 { + h.hover_at(edge + (to - edge) * (k as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(8); + Ok(()) +} + +fn click_page(h: &mut Harness<'static, PdfCraftApp>, session: &Session, x: f32, y: f32) -> Result<(), String> { + let page = h.state().views.first().and_then(|v| v.page_screen_rect(0)).ok_or("the page is not on screen")?; + let [pw, ph] = session.page; + let at = egui::pos2(page.left() + x / pw * page.width(), page.top() + (ph - y) / ph * page.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + Ok(()) +} + +fn modifiers_of(names: &[String]) -> Result { + let mut m = Modifiers::NONE; + for name in names { + match name.as_str() { + "command" | "cmd" => m.command = true, + "shift" => m.shift = true, + "alt" | "option" => m.alt = true, + "ctrl" | "control" => m.ctrl = true, + other => return Err(format!("unknown modifier {other:?}")), + } + } + if m.command { + m.mac_cmd = cfg!(target_os = "macos"); + m.ctrl |= !cfg!(target_os = "macos"); + } + Ok(m) +} + +fn paragraphs(h: &Harness<'static, PdfCraftApp>) -> Vec { + paragraphs_full(h).into_iter().map(|b| b.text).collect() +} + +fn paragraphs_full(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + let Some(view) = s.views.first() else { return Vec::new() }; + s.session.get(view.id).map(|d| d.text_blocks(0)).unwrap_or_default() +} + +fn save_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) -> String { + let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); + save_named(h, &safe) +} + +fn save_named(h: &mut Harness<'static, PdfCraftApp>, name: &str) -> String { + for _ in 0..20 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + } + let dir = PathBuf::from("/tmp/pdfcraft-play"); + let _ = fs::create_dir_all(&dir); + let path = dir.join(format!("{name}.png")); + if let Ok(image) = h.render() { + let _ = image.save(&path); + } + path.display().to_string() +} + +fn open_letter(name: &str, content: &str, desk: &Desk) -> Harness<'static, PdfCraftApp> { + let bytes = letter_pdf(content); + let title = name.to_string(); + let mut h = Harness::builder().with_size(egui::vec2(desk.width, desk.height)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + let _ = app.set_option("language", "en"); + let _ = app.open_bytes(&title, None, bytes); + app + }); + h.run_steps(4); + h +} + +fn letter_pdf(content: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\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 offset in offsets { + out.extend_from_slice(format!("{offset: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 +} diff --git a/crates/ui-egui/tests/play/blank-line-edit.json b/crates/ui-egui/tests/play/blank-line-edit.json new file mode 100644 index 0000000000..2f7f2602c7 --- /dev/null +++ b/crates/ui-egui/tests/play/blank-line-edit.json @@ -0,0 +1,17 @@ +{ + "name": "blank line edit", + "content": "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 140, "y": 554}}, + {"expect_editor": {"contains": "First paragraph, first row", "text": "First paragraph, first row.\nFirst paragraph, second row."}}, + {"type": {"text": "First paragraph, first row.\nChanged."}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"expect_together": {"parts": ["First paragraph, first row", "Changed"]}}, + {"expect_together": {"parts": ["Second paragraph, first row", "Second paragraph, second row"]}}, + {"expect_apart": {"text": "Second paragraph, first row", "not": "Changed"}}, + {"click_page": {"x": 140, "y": 510}}, + {"expect_editor": {"contains": "Second paragraph, first row", "text": "Second paragraph, first row.\nSecond paragraph, second row."}}, + {"shot": {"name": "blank-line-edit"}} + ] +} diff --git a/crates/ui-egui/tests/play/partial-rewrite-keeps-neighbor.json b/crates/ui-egui/tests/play/partial-rewrite-keeps-neighbor.json new file mode 100644 index 0000000000..5f96c33683 --- /dev/null +++ b/crates/ui-egui/tests/play/partial-rewrite-keeps-neighbor.json @@ -0,0 +1,18 @@ +{ + "name": "partial rewrite keeps neighbor", + "content": "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 120, "y": 704}}, + {"type": {"text": "Alpha first row.\nTwo\nThree"}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"click_page": {"x": 120, "y": 660}}, + {"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}}, + {"click_page": {"x": 120, "y": 704}}, + {"type": {"text": "Alpha first row.\nTwo\nFour"}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"click_page": {"x": 120, "y": 660}}, + {"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}}, + {"shot": {"name": "partial-rewrite-keeps-neighbor"}} + ] +} diff --git a/crates/ui-egui/tests/play/repeated-step-opens-as-one.json b/crates/ui-egui/tests/play/repeated-step-opens-as-one.json new file mode 100644 index 0000000000..d9deb3858b --- /dev/null +++ b/crates/ui-egui/tests/play/repeated-step-opens-as-one.json @@ -0,0 +1,11 @@ +{ + "name": "repeated step opens as one", + "content": "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 500 Tm (Kept.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 90, "y": 676}}, + {"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}}, + {"click_page": {"x": 90, "y": 504}}, + {"expect_editor": {"contains": "Kept", "text": "Kept."}} + ] +} diff --git a/crates/ui-egui/tests/play/repeated-step-td-inside-one-bt.json b/crates/ui-egui/tests/play/repeated-step-td-inside-one-bt.json new file mode 100644 index 0000000000..3953f413d1 --- /dev/null +++ b/crates/ui-egui/tests/play/repeated-step-td-inside-one-bt.json @@ -0,0 +1,9 @@ +{ + "name": "repeated step Td inside one BT", + "content": "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -27.6 Td (Two) Tj 0 -27.6 Td (Three) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 90, "y": 676}}, + {"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}} + ] +} diff --git a/crates/ui-egui/tests/play/repeated-step-tm.json b/crates/ui-egui/tests/play/repeated-step-tm.json new file mode 100644 index 0000000000..90c8d3c4fe --- /dev/null +++ b/crates/ui-egui/tests/play/repeated-step-tm.json @@ -0,0 +1,9 @@ +{ + "name": "repeated step one Tm per row", + "content": "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 672.4 Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 644.8 Tm (Three) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 90, "y": 676}}, + {"expect_editor": {"contains": "Two", "text": "One\nTwo\nThree"}} + ] +} diff --git a/crates/ui-egui/tests/play/resize-grown-neighbor.json b/crates/ui-egui/tests/play/resize-grown-neighbor.json new file mode 100644 index 0000000000..fbd208e828 --- /dev/null +++ b/crates/ui-egui/tests/play/resize-grown-neighbor.json @@ -0,0 +1,19 @@ +{ + "name": "resize grown neighbor", + "content": "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 120, "y": 414}}, + {"type": {"text": "Alpha first row.\nthis is another\nalpha test\nAlpha next row."}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}}, + {"drag_edge": {"contains": "Alpha next row", "dx": 160}}, + {"expect_together": {"parts": ["Alpha first row.", "this is another", "alpha test", "Alpha next row."]}}, + {"drag_edge": {"contains": "Alpha next row", "dx": -80}}, + {"expect_together": {"parts": ["Alpha first row.", "this is another", "alpha test", "Alpha next row."]}}, + {"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}}, + {"click_page": {"x": 110, "y": 370}}, + {"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}}, + {"shot": {"name": "resize-grown-neighbor"}} + ] +} diff --git a/crates/ui-egui/tests/play/widen-example-one.json b/crates/ui-egui/tests/play/widen-example-one.json new file mode 100644 index 0000000000..615ef6668a --- /dev/null +++ b/crates/ui-egui/tests/play/widen-example-one.json @@ -0,0 +1,19 @@ +{ + "name": "widen example 1", + "content": "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 672.4 Td (Two) Tj ET BT /F1 12 Tf 72 644.8 Td (Three) Tj ET BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 90, "y": 690}}, + {"type": {"text": "One\nTwo\nThree\nfour"}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"drag_edge": {"contains": "four", "dx": 280}}, + {"expect_editor": {"contains": "four", "text": "One\nTwo\nThree\nfour"}}, + {"expect_paragraph": {"text": "1. Repeated step, no TL. Click Two."}}, + {"expect_together": {"parts": ["First paragraph, first row", "First paragraph, second row"]}}, + {"expect_together": {"parts": ["Second paragraph, first row", "Second paragraph, second row"]}}, + {"expect_apart": {"text": "Neighbor stays.", "not": "Alpha"}}, + {"click_page": {"x": 110, "y": 370}}, + {"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}}, + {"shot": {"name": "widen-example-one"}} + ] +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f14ce86754..39d105bd24 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading: the measured baseline gap equals it, a gap of that size stays one paragraph even above twice the font size, and a gap of twice the TL is the next paragraph. A rewrite of every row is marked, so a new row one leading above the next paragraph does not absorb it on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478)" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] id = "edit.font-reuse-substitution" From a84ae042d1acf911a74dca03c04afc641571eadc Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:26:30 +0200 Subject: [PATCH 33/82] M7: close a partial rewrite's marker after the paragraph (#2517) A row left in the source stays in the last paragraph. An extra marker is not placed across two marked-content regions. --- ROADMAP.md | 2 + crates/automation/tests/it/automation.rs | 33 +++++ crates/edit/src/tests.rs | 139 ++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 85 +++++++++-- .../play/last-paragraph-keeps-both-rows.json | 14 ++ .../play/marked-region-keeps-neighbor.json | 15 ++ docs/gaps.md | 5 +- docs/roadmap.md | 2 + parity/acrobat-features.toml | 4 +- 9 files changed, 283 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/tests/play/last-paragraph-keeps-both-rows.json create mode 100644 crates/ui-egui/tests/play/marked-region-keeps-neighbor.json diff --git a/ROADMAP.md b/ROADMAP.md index de6d8e9692..83cfd66532 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,6 +21,7 @@ > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -204,6 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-18 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. The extra marker is not added across two marked-content regions. Estimates unchanged. - **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 2f000fb0c6..226796cfe4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9579,3 +9579,36 @@ fn paragraph_grouping_survives_save_and_reopen_through_tools() { let extracted = page_text(&mut a, again); assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}"); } + +/// Editing the only paragraph on the page, while leaving its second row in the source, stays +/// one paragraph through save and reopen. A neighbour in a different marked region stays in +/// its own paragraph. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() { + let dir = workdir("paragraph-tail"); + let mut a = auto(&dir); + let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]); + + let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 2d17252bc5..916872a861 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2986,6 +2986,145 @@ fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() { assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}"); } +/// The paragraph is the last thing on the page. Editing the first row and leaving the second +/// in the source must keep both rows inside one marker, including after a save. +#[test] +fn a_partial_rewrite_of_the_only_paragraph_stays_one_block() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let mut doc = doc; + text::replace_block(&mut doc, 0, 0, "One.\nTwo.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Two."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Two.)"); +} + +/// The same rewrite when an operator follows the paragraph's `ET`. The close belongs before +/// that operator, and still after the kept row. +#[test] +fn a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET 0 0 0 RG"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/P BMC", "(One.)", "(Alpha next row.)"); + let start = content.find("/P BMC").expect("marker"); + let close = content[start..].find("EMC").map(|at| start + at).expect("close"); + let after = content.find("0 0 0 RG").expect("the following operator is gone"); + assert!(close < after, "the marker swallowed the operator after the paragraph: {content}"); +} + +/// The last paragraph on the page is already inside a marked region. A partial rewrite stays +/// one paragraph, and that region's close still comes after both rows. +#[test] +fn a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/MCID 0", "(One.)", "(Alpha next row.)"); +} + +/// A partial rewrite must not insert `/P BMC` across two existing marked-content regions. +/// The neighbour's own marker has to keep wrapping its text. +#[test] +fn a_partial_rewrite_keeps_the_next_marked_region_closed() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 0", "(One.)", "(Alpha next row.)"); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + let neighbour = content.find("/MCID 1").expect("neighbour region"); + let close = content[neighbour..].find("EMC").map(|at| neighbour + at).expect("neighbour close"); + assert!(!content[neighbour..close].contains("(One.)"), "the rewrite was pulled into the neighbour's marker: {content}"); +} + +/// Both paragraphs already share one marked region, separated by a blank line. The extra marker +/// around the edit has to close itself, not the region, so the neighbour stays inside it. +#[test] +fn a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it() { + let src = "/P << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + let region = region_through_matching_close(&content, "/MCID 0"); + assert!(region.contains("(One.)") && region.contains("(Neighbor stays.)"), "the region closed before the neighbour: {content}"); +} + +/// Bytes from `open` through the `EMC` that balances the marked region it begins. +fn region_through_matching_close<'a>(content: &'a str, open: &str) -> &'a str { + let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}")); + let bytes = content.as_bytes(); + let mut index = start; + let mut depth = 0i32; + while index < bytes.len() { + let rest = &bytes[index..]; + let boundary = index == 0 || !bytes[index - 1].is_ascii_alphanumeric(); + if boundary && (rest.starts_with(b"BMC") || rest.starts_with(b"BDC")) && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth += 1; + index += 3; + continue; + } + if boundary && rest.starts_with(b"EMC") && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth -= 1; + if depth == 0 { + return &content[start..index]; + } + index += 3; + continue; + } + index += 1; + } + panic!("unbalanced region {open}: {content}"); +} + +fn page_content(doc: &Document) -> String { + String::from_utf8_lossy(&page_content_bytes(doc, 0)).into_owned() +} + +/// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object. +fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) { + let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}")); + let end = content[start..].find("EMC").map(|at| start + at).unwrap_or_else(|| panic!("missing EMC after {open}: {content}")); + let marked = &content[start..end]; + assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}"); + let mut text_objects = 0i32; + for token in content.split_whitespace() { + match token { + "BT" => text_objects += 1, + "ET" => text_objects -= 1, + "EMC" => assert_eq!(text_objects, 0, "EMC inside a text object: {content}"), + _ => {} + } + } + assert_eq!(text_objects, 0, "a text object was left open: {content}"); +} + /// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the /// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate /// drawing and delete it. diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index a9f3711072..ccb75b3130 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -610,7 +610,7 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range } /// Operator index of the paragraph's first text object, and of the next line's text object -/// when one follows. The next index is where a closing marker belongs. +/// when one follows. fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option)> { let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?; @@ -625,6 +625,50 @@ fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Optio Some((start, next)) } +/// The scope shared by every line of the paragraph, when each one is in the same marked region. +fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option { + let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope); + let first = scopes.next().flatten()?; + scopes.all(|scope| scope == Some(first)).then_some(first) +} + +/// Where an extra `/P` marker belongs. +/// +/// The marker is omitted when the paragraph is already one marked region and the next line is +/// in a different one: an `EMC` at that line's `BT` lands after the other region has opened, so +/// it would close the other region and leave its text outside. When nothing follows, `close` is +/// the operator just after the paragraph's last `ET` (`ops.len()` means append it after the +/// stream). Closing earlier, inside the rewritten operators, leaves a kept source row outside +/// the marker and can put `EMC` inside a `BT`. +fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option, usize)> { + let (start, next) = paragraph_span(block, lines)?; + if let Some(region) = shared_scope(block, lines) + && let Some(at) = next + { + let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope); + if next_scope != Some(Some(region)) { + return None; + } + } + let close = if next.is_none() { + let end = block + .lines + .iter() + .filter_map(|i| lines.get(*i)) + .flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))) + .max()?; + ops.iter() + .enumerate() + .skip(end.saturating_add(1)) + .find(|(_, op)| op.op.as_slice() == b"ET") + .map(|(index, _)| index.saturating_add(1)) + .unwrap_or(ops.len()) + } else { + 0 + }; + Some((start, next, close)) +} + struct RewriteAnchor<'a> { lead: &'a TextLine, drop: std::collections::HashSet, @@ -1001,26 +1045,41 @@ pub(crate) fn rewrite_styled_block( block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } let at = if split { start } else { o.bt_op }; - // A kept source row sits outside `block_ops`, so a marker only around the rewrite splits - // the paragraph or leaves the added row to join the neighbour. Bound the whole paragraph: - // from its first text object up to, but not including, the next line. - let marker = if marked { None } else { paragraph_span(block, lines) }; - if marker.is_some_and(|(_, next)| next.is_none()) { - block_ops.push(Op::new("EMC", vec![])); - } + // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is + // safe. The close is not part of `block_ops`: that vector can end inside a text object + // when the rewrite splits one, and a kept row's operators come later in the stream. + let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; let data = splice(&pieces, &ops, |i| { let mut insert = Vec::new(); - if marker.is_some_and(|(start_at, _)| i == start_at) { - insert.push(Op::new("BMC", vec![Object::name("P")])); + if let Some((start_at, next, close)) = marker { + if i == start_at { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if next.is_none() && i == close { + insert.push(Op::new("EMC", vec![])); + } + if next == Some(i) { + insert.push(Op::new("EMC", vec![])); + } } if i == at { insert.append(&mut block_ops); } - if marker.is_some_and(|(_, next)| next == Some(i)) { - insert.push(Op::new("EMC", vec![])); - } (insert, !drop.contains(&i)) }); + let data = if marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { + let mut data = data; + if let Some(last) = data.last_mut() { + if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { + last.push(b'\n'); + } + // `splice` never visits `ops.len()`, so a close after the final operator is appended. + last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); + } + data + } else { + data + }; let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/ui-egui/tests/play/last-paragraph-keeps-both-rows.json b/crates/ui-egui/tests/play/last-paragraph-keeps-both-rows.json new file mode 100644 index 0000000000..fdd6729db9 --- /dev/null +++ b/crates/ui-egui/tests/play/last-paragraph-keeps-both-rows.json @@ -0,0 +1,14 @@ +{ + "name": "last paragraph keeps both rows", + "content": "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 120, "y": 704}}, + {"type": {"text": "One.\nAlpha next row."}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"expect_together": {"parts": ["One.", "Alpha next row."]}}, + {"click_page": {"x": 120, "y": 704}}, + {"expect_editor": {"contains": "One.", "text": "One.\nAlpha next row."}}, + {"shot": {"name": "last-paragraph-keeps-both-rows"}} + ] +} diff --git a/crates/ui-egui/tests/play/marked-region-keeps-neighbor.json b/crates/ui-egui/tests/play/marked-region-keeps-neighbor.json new file mode 100644 index 0000000000..cd5da6d78e --- /dev/null +++ b/crates/ui-egui/tests/play/marked-region-keeps-neighbor.json @@ -0,0 +1,15 @@ +{ + "name": "marked region keeps neighbor", + "content": "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 120, "y": 704}}, + {"type": {"text": "One.\nAlpha next row."}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"expect_together": {"parts": ["One.", "Alpha next row."]}}, + {"expect_apart": {"text": "Neighbor stays.", "not": "One."}}, + {"click_page": {"x": 140, "y": 660}}, + {"expect_editor": {"contains": "Neighbor stays.", "text": "Neighbor stays."}}, + {"shot": {"name": "marked-region-keeps-neighbor"}} + ] +} diff --git a/docs/gaps.md b/docs/gaps.md index 75b0007e27..9457fd7595 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,6 +5,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -103,7 +104,8 @@ update the numbers. removes a distinct neighbour whose glyph box overlaps (#1316); a second drawing on the same baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the measured baseline gap, and growing a paragraph so its new row is one leading above the next - one does not absorb that paragraph (#2517). A heading and smaller body lines (#1756), a + one does not absorb that paragraph (#2517). A partial rewrite of the last paragraph keeps a + source row inside that paragraph, and does not close a following marked region early. A heading and smaller body lines (#1756), a hanging-indent continuation (#2564), and cells in different columns (#1680, #2478) are still grouped by the same geometry. Estimates are unchanged. @@ -409,6 +411,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | +| 2026-10-18 | minor | Gap 3: #2517 a partial rewrite keeps a kept source row in the paragraph and leaves the next marked region closed around its text. Estimates unchanged. | | 2026-10-15 | minor | Gap 3: #2517 a blank line at ordinary leading stays a paragraph break. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | diff --git a/docs/roadmap.md b/docs/roadmap.md index d90c65a67c..184a1b7a37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,6 +9,7 @@ > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -265,6 +266,7 @@ Estimates unchanged. | 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | +| 2026-10-18 | minor | M7 #2517: a partial rewrite of the last paragraph closes its marker after the last `ET`, and does not close a following marked region early. Estimates unchanged. | | 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-21 | minor | M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 39d105bd24..c7069200ab 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] id = "edit.font-reuse-substitution" From 2bc2933988a00b0d9e445628c2a46d0d855fa426 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:16:47 +0200 Subject: [PATCH 34/82] M7: rejoin a wrapped line when the box is widened (#2517) A newline that was typed stays. A wrap is marked so a later width change can reflow it, and a short unmarked line stays broken. --- .gitignore | 1 + crates/edit/src/tests.rs | 88 ++++++++++++++++++++---- crates/edit/src/text.rs | 47 ++++++++----- crates/edit/src/text/blocks.rs | 14 ++-- crates/edit/src/text/interp.rs | 38 +++++++++- crates/edit/src/text/lines.rs | 4 ++ crates/edit/src/text/rewrite.rs | 27 ++++++-- crates/edit/src/text_regions/grouping.rs | 49 +++++++++++++ crates/edit/src/text_regions/styled.rs | 22 +++++- crates/ui-egui/src/tools/edit_text_ui.rs | 7 +- crates/ui-egui/tests/it/editing.rs | 4 +- parity/acrobat-features.toml | 4 +- 12 files changed, 255 insertions(+), 50 deletions(-) diff --git a/.gitignore b/.gitignore index 7834b668cb..f7922460c7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /assets/models # A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here). /craft-fonts +.claude/ diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 916872a861..800481d7d0 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2807,9 +2807,8 @@ fn a_blank_line_keeps_the_next_paragraph() { // The second row is left in the source stream. A marker around only the rewritten row // would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph. let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - if let Some(start) = content.find("/P BMC") { - let end = start + content[start..].find("EMC").unwrap_or(content.len() - start); - let marked = &content[start..end]; + if content.contains("/P BMC") { + let marked = region_through_matching_close(&content, "/P BMC"); assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}"); assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}"); } @@ -2885,8 +2884,7 @@ fn growing_into_a_blank_line_keeps_the_neighbour_after_save() { let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]); let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); - let marked = content.find("/P BMC").and_then(|start| content[start..].find("EMC").map(|end| content[start..start + end].to_owned())); - let marked = marked.unwrap_or_else(|| panic!("a full rewrite did not mark its rows: {content}")); + let marked = region_through_matching_close(&content, "/P BMC"); for row in ["(One)", "(Two)", "(Three)"] { assert!(marked.contains(row), "{row} is outside the marker: {content}"); } @@ -3109,20 +3107,42 @@ fn page_content(doc: &Document) -> String { /// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object. fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) { - let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}")); - let end = content[start..].find("EMC").map(|at| start + at).unwrap_or_else(|| panic!("missing EMC after {open}: {content}")); - let marked = &content[start..end]; + let marked = region_through_matching_close(content, open); assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}"); - let mut text_objects = 0i32; + assert!(!marker_crosses_a_text_object(content), "a marker opened outside a text object and closed inside it: {content}"); +} + +/// A marker may sit inside one text object. It may not open outside one and close inside it. +fn marker_crosses_a_text_object(content: &str) -> bool { + let mut text = 0i32; + let mut opened_outside = 0i32; + let mut opened_inside = 0i32; for token in content.split_whitespace() { match token { - "BT" => text_objects += 1, - "ET" => text_objects -= 1, - "EMC" => assert_eq!(text_objects, 0, "EMC inside a text object: {content}"), + "BT" => text += 1, + "ET" => text -= 1, + "BMC" | "BDC" => { + if text == 0 { + opened_outside += 1; + } else { + opened_inside += 1; + } + } + "EMC" => { + if text > 0 { + if opened_inside > 0 { + opened_inside -= 1; + } else { + return true; + } + } else if opened_outside > 0 { + opened_outside -= 1; + } + } _ => {} } } - assert_eq!(text_objects, 0, "a text object was left open: {content}"); + text != 0 || opened_outside != 0 || opened_inside != 0 } /// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the @@ -3149,6 +3169,45 @@ fn resizing_a_grown_paragraph_keeps_the_neighbour() { } } +/// Narrowing wraps a line. Widening it again rejoins that wrap. A newline that was typed stays. +#[test] +fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { + let mut doc = text_page("BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert!(block.editor_text.contains('\n'), "the narrow box did not wrap: {}", block.editor_text); + assert_eq!(block.reflow_text, "Page 1", "a wrap was kept as a hard break: {}", block.reflow_text); + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap(); + let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["Page 1"]); + + let mut typed = text_page("BT /F1 12 Tf 72 700 Td (One) Tj ET"); + text::replace_block(&mut typed, 0, 0, "One\nTwo\nThree\nfour").unwrap(); + let block = text::text_blocks(&typed, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo\nThree\nfour", "a typed break was rejoined: {}", block.reflow_text); + let width = (block.rect[2] - block.rect[0]) * 6.0; + text::rewrite_block(&mut typed, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(width.max(180.0)), ..Default::default() }) + .unwrap(); + assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour"); +} + +/// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a +/// short line that had room. +#[test] +fn an_unmarked_full_line_rejoins_and_a_short_break_stays() { + let long = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (This sentence is long enough to fill a measure.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Tail) Tj ET"; + let doc = text_page(long); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.rows.len(), 2, "{}", block.editor_text); + assert_eq!(block.reflow_text, "This sentence is long enough to fill a measure. Tail", "{}", block.reflow_text); + + let short = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Two) Tj ET"; + let doc = text_page(short); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo", "{}", block.reflow_text); +} + /// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the /// heading above it and both paragraphs under example 2 as they were. fn letter_page() -> &'static str { @@ -3181,7 +3240,8 @@ fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap(); let blocks = text::text_blocks(&doc, 0).unwrap(); let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap(); - let shown = blocks[idx].editor_text.clone(); + assert_eq!(blocks[idx].reflow_text, "One\nTwo\nThree\nfour", "a typed break was treated as a wrap: {}", blocks[idx].reflow_text); + let shown = blocks[idx].reflow_text.clone(); let width = blocks[idx].rect[2] - blocks[idx].rect[0]; text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap(); let doc = reopen(&doc); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index eb45209353..7bcff773a8 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -118,6 +118,10 @@ pub struct TextLine { /// `ink`, and whether joining put a space before it. Empty when the line's text was put /// into another order (see [`TextLine::runs`]). runs: Vec<(String, usize, bool)>, + /// This line continues the previous one because a rewrite wrapped it (`/PCSoft`). + soft_break: bool, + /// This line starts after a break a rewrite kept on purpose (`/PCHard`). + hard_break: bool, } impl TextLine { @@ -154,6 +158,7 @@ mod tests { lines: Vec::new(), rows: Vec::new(), editor_text: text.into(), + reflow_text: text.into(), spans: Vec::new(), invisible: false, } @@ -204,40 +209,48 @@ mod tests { assert!(!same_base_font("Arial", "")); } + fn wrapped(text: &str, width: f64) -> Vec { + wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _)| line).collect() + } + #[test] fn paragraph_wrapping_keeps_ideographic_spaces() { + assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]); + assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]); + assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]); + } + + #[test] + fn a_wrap_is_marked_soft_and_a_newline_is_not() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]); - assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); + assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false), ("1".into(), true)]); + assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false), ("Two".into(), false)]); } /// #4199: a run of spaces stays a run, before, between and after words; the spaces where a /// row wraps go, and each other white-space character is one space. #[test] fn paragraph_wrapping_keeps_every_space_typed() { - let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("a b c", 20.0, width), ["a b c"]); - assert_eq!(wrap(" a b ", 20.0, width), [" a b "]); - assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]); - assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]); + assert_eq!(wrapped("a b c", 20.0), ["a b c"]); + assert_eq!(wrapped(" a b ", 20.0), [" a b "]); + assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]); + assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]); // An indent alone on a row doesn't wrap before its first word. - assert_eq!(wrap(" abc", 3.0, width), [" abc"]); + assert_eq!(wrapped(" abc", 3.0), [" abc"]); // The rewrite turns `\r\n` and `\r` into `\n` before wrapping. - assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]); - assert_eq!(wrap(" ", 3.0, width), [" "]); - assert_eq!(wrap("", 3.0, width), [""]); + assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]); + assert_eq!(wrapped(" ", 3.0), [" "]); + assert_eq!(wrapped("", 3.0), [""]); } #[test] fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { - let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]); // The comma can't begin a row, so the character before it moves down with it. - assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]); // Pieces of one word join without a space; separate words keep theirs. - assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); - assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]); + assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..2785f87676 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -25,6 +25,9 @@ pub struct TextBlock { pub rows: Vec>, /// Text for the inline editor, with a newline only between visual rows. pub editor_text: String, + /// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a + /// short line that still had room for the next word, stays a newline. + pub reflow_text: String, /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose @@ -337,11 +340,13 @@ pub(crate) fn paragraph_separator(c: char) -> bool { /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so /// a run of spaces typed between, before or after words stays (#4199); only the spaces where a -/// row wraps are dropped. -pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { +/// row wraps are dropped. The bool is true when the line continues the previous one because the +/// next word did not fit, rather than because of a newline. +pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool)> { let mut out = Vec::new(); for row in text.split('\n') { let mut line = String::new(); + let mut soft = false; let start = out.len(); // The separators read since the last word: each one boundary between `split` pieces. let mut gap = 0usize; @@ -357,7 +362,8 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") }; // A row holding only the spaces that indent it doesn't wrap before its first word. if line.chars().any(|c| c != ' ') && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); + out.push((std::mem::take(&mut line), soft)); + soft = true; line = piece.to_string(); } else { line = candidate; @@ -366,7 +372,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec } line.push_str(&" ".repeat(gap)); if !line.is_empty() || out.len() == start { - out.push(line); + out.push((line, soft)); } } out diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 16b04b406f..e3e4bf26b3 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -301,6 +301,9 @@ pub(super) struct Shown { pub(super) paint: Paint, pub(super) logical_order: bool, pub(super) actual_text: bool, + /// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line. + pub(super) soft_break: bool, + pub(super) hard_break: bool, } impl Shown { @@ -549,6 +552,8 @@ struct TextRunState<'a> { marked: &'a MarkedContent, carry: &'a mut Carry, out: &'a mut Vec, + soft_break: bool, + hard_break: bool, } fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { @@ -745,6 +750,8 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, logical_order: false, actual_text: false, + soft_break: state.soft_break, + hard_break: state.hard_break, }); Ok(()) } @@ -769,11 +776,22 @@ pub(super) fn interpret( let mut bt = 0usize; let mut marked = MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; + // A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker + // before the next line. Neither name is a paragraph region, so it does not change scope. + let mut soft_break = false; + let mut hard_break = false; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { + if op.name(0) == Some(b"PCSoft") { + soft_break = true; + hard_break = false; + } else if op.name(0) == Some(b"PCHard") { + hard_break = true; + soft_break = false; + } marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { @@ -878,8 +896,26 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + let before = out.len(); + let mut state = TextRunState { + ts: &mut ts, + tm: &mut tm, + tlm: &mut tlm, + at_bt: &at_bt, + bt, + marked: &marked, + carry, + out: &mut out, + soft_break, + hard_break, + }; show_text(op, i, &mut state)?; + // A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break + // belongs to whichever text-showing operator comes next. + if out.len() > before { + soft_break = false; + hard_break = false; + } } _ => {} } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index c48522984d..78aec5090f 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -280,6 +280,8 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -394,6 +396,8 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..9589fad226 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -579,13 +579,28 @@ impl LineWriter<'_> { } /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. - fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + fn write_line( + &self, + index: usize, + line: &str, + soft: bool, + last: bool, + cursor: &mut LineCursor, + ops: &mut Vec, + ) -> Result<(f64, f64, f64), EditError> { let n = pdfcraft_content::num; let body = line.trim_matches(' '); let interior = body.matches(' ').count(); let tw = self.stretch(body, last); let (shown, visible) = self.shown(line, body, tw); let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + if index > 0 { + // Inside this text object, so the marker starts and ends here. `/PCSoft` rejoins + // when the box widens. `/PCHard` is a newline that was already in the text. + let name = if soft { "PCSoft" } else { "PCHard" }; + ops.push(Op::new("BMC", vec![Object::name(name)])); + ops.push(Op::new("EMC", vec![])); + } if index > 0 || dx != 0.0 { ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); } @@ -633,8 +648,12 @@ impl LineWriter<'_> { } /// Writes every wrapped line, returning their underlines. - fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { - wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + fn write_all(&self, wrapped: &[(String, bool)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + wrapped + .iter() + .enumerate() + .map(|(i, (line, soft))| self.write_line(i, line, *soft, i + 1 == wrapped.len(), cursor, ops)) + .collect() } } @@ -653,7 +672,7 @@ struct BlockPlan<'a> { underline: bool, } -fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], show_font: String) -> Result, EditError> { let n = pdfcraft_content::num; let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 68a83569a5..f866daef5e 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -299,6 +299,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { blocks.push(TextBlock { text: String::new(), editor_text: String::new(), + reflow_text: String::new(), spans: Vec::new(), rect, base_font: first.base_font.clone(), @@ -346,5 +347,53 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { block.rect = union(block.rect, rect); block.rows.push(row.clone()); } + for block in &mut blocks { + block.reflow_text = reflow_text(block, lines); + } blocks } + +/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard` +/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches +/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a +/// file written elsewhere wraps a body line. +fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { + let parts: Vec<&str> = block.editor_text.split('\n').collect(); + if parts.len() != block.rows.len() { + return block.editor_text.clone(); + } + let mut out = String::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + if break_is_soft(block, lines, index) { + if out.ends_with('-') { + out.pop(); + } else if !out.ends_with(char::is_whitespace) { + out.push(' '); + } + } else { + out.push('\n'); + } + } + out.push_str(part); + } + out +} + +fn break_is_soft(block: &TextBlock, lines: &[TextLine], row_index: usize) -> bool { + let Some(line) = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i)) else { return false }; + if line.hard_break { + return false; + } + if line.soft_break { + return true; + } + let Some(prev) = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines)) else { return false }; + let Some(next) = block.rows.get(row_index).and_then(|row| row_rect(row, lines)) else { return false }; + let size = line.size.max(1.0); + let slack = block.rect[2] - prev[2]; + let width = prev[2] - prev[0]; + // Short rows define the right edge by their own letters. A body line that fills the + // measure leaves almost no room for the next word. + slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index ccb75b3130..f55d52ddc6 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -405,9 +405,12 @@ fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, -) -> Result<(StyledRows, RowRanges), EditError> { +) -> Result<(StyledRows, RowRanges, Vec), EditError> { let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); + // True when this row starts because the previous word did not fit, not because of a newline. + let mut row_soft = Vec::new(); + let mut next_soft = false; let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; @@ -419,6 +422,8 @@ fn wrap_styled_rows( continue; } if item.0 == '\n' { + row_soft.push(next_soft); + next_soft = false; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -442,6 +447,8 @@ fn wrap_styled_rows( while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { row.pop(); } + row_soft.push(next_soft); + next_soft = true; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -452,10 +459,11 @@ fn wrap_styled_rows( at = end; } if !row.is_empty() { + row_soft.push(next_soft); row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - Ok((rows, row_ranges)) + Ok((rows, row_ranges, row_soft)) } fn apply_range_styles<'a>( @@ -822,7 +830,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; + let (rows, row_ranges, row_soft) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -891,6 +899,14 @@ pub(crate) fn rewrite_styled_block( baseline -= source_leading; continue; } + if !row.is_empty() && row_index > 0 { + // An empty marker between lines. `/PCSoft` is a wrap and rejoins when the box + // widens. `/PCHard` is a newline that was already in the text. Neither is a + // paragraph region, so the lines stay in one scope. + let name = if row_soft.get(row_index).copied().unwrap_or(false) { "PCSoft" } else { "PCHard" }; + block_ops.push(Op::new("BMC", vec![Object::name(name)])); + block_ops.push(Op::new("EMC", vec![])); + } let w = measured.get(row_index).copied().unwrap_or(0.0); let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index c932ed3608..c8bf9867b2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -399,9 +399,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); let shift = to[0] - from[0]; let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; - // `text` joins rows with spaces. The editor text is what the paragraph actually - // is, line breaks included; a width change must not throw those breaks away. - Edit::EditTextBlock { page, block: d.block, text: l.editor_text.clone(), style } + // `editor_text` has a newline for every visual row, so a wrap would come back as a + // hard break and a wider box would not reflow it. `reflow_text` rejoins a wrap + // and keeps a newline that was typed. + Edit::EditTextBlock { page, block: d.block, text: l.reflow_text.clone(), style } } else { // Translation must keep the selected source operators. Rewriting unchanged text // would also remove unrelated paragraphs that now overlap this one (#594). diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 5a124aa390..95ad044b31 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -3738,7 +3738,7 @@ fn the_live_editor_groups_a_repeated_step_and_keeps_the_neighbour() { 700.0 - step, 700.0 - 2.0 * step ); - let mut h = open_letter("repeated.pdf", letter_text(&repeated)); + let mut h = open_letter("repeated.pdf", letter_text(repeated.as_str())); assert!(h.state_mut().execute("edit.edit_text")); h.run_steps(2); click_page(&mut h, 90.0, 700.0 - step + 4.0); @@ -3785,7 +3785,7 @@ fn widening_example_one_keeps_the_heading_and_the_next_paragraph() { BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \ BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; - let mut h = open_letter("letter.pdf", letter_text(&body)); + let mut h = open_letter("letter.pdf", letter_text(body)); assert!(h.state_mut().execute("edit.edit_text")); h.run_steps(2); click_page(&mut h, 90.0, 690.0); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c7069200ab..c9d371cc56 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1902,8 +1902,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] id = "edit.paragraph-detection" From cb832b0206727942b5ed1403ca7c93742dec95b5 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:50:21 +0200 Subject: [PATCH 35/82] M7: close a partial rewrite before the next marked region (#2517) An unmarked paragraph was closing its marker at the following line's BT, which is after that line's BDC. The close now follows this paragraph's last ET. --- crates/edit/src/tests.rs | 17 ++++++++++++ crates/edit/src/text_regions/styled.rs | 38 +++++++++++--------------- parity/acrobat-features.toml | 2 +- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 800481d7d0..81279270a9 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3057,6 +3057,23 @@ fn a_partial_rewrite_keeps_the_next_marked_region_closed() { assert!(!content[neighbour..close].contains("(One.)"), "the rewrite was pulled into the neighbour's marker: {content}"); } +/// The paragraph being edited is not in a marked region. The next one is. Closing the new +/// marker at the neighbour's `BT` would land after that region's `BDC` and close it early. +#[test] +fn a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + assert!(!region_through_matching_close(&content, "/MCID 1").contains("(One.)"), "the rewrite was pulled into the neighbour: {content}"); +} + /// Both paragraphs already share one marked region, separated by a blank line. The extra marker /// around the edit has to close itself, not the region, so the neighbour stays inside it. #[test] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index f55d52ddc6..557a86f1d6 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -643,11 +643,10 @@ fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option { /// Where an extra `/P` marker belongs. /// /// The marker is omitted when the paragraph is already one marked region and the next line is -/// in a different one: an `EMC` at that line's `BT` lands after the other region has opened, so -/// it would close the other region and leave its text outside. When nothing follows, `close` is -/// the operator just after the paragraph's last `ET` (`ops.len()` means append it after the -/// stream). Closing earlier, inside the rewritten operators, leaves a kept source row outside -/// the marker and can put `EMC` inside a `BT`. +/// in a different one. Otherwise it closes after the paragraph's last `ET` (`ops.len()` means +/// append it after the stream). Closing at the next line's `BT` is too late when that line's +/// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing +/// inside the rewritten operators leaves a kept source row outside the marker. fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option, usize)> { let (start, next) = paragraph_span(block, lines)?; if let Some(region) = shared_scope(block, lines) @@ -658,23 +657,18 @@ fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option return None; } } - let close = if next.is_none() { - let end = block - .lines - .iter() - .filter_map(|i| lines.get(*i)) - .flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))) - .max()?; - ops.iter() - .enumerate() - .skip(end.saturating_add(1)) - .find(|(_, op)| op.op.as_slice() == b"ET") - .map(|(index, _)| index.saturating_add(1)) - .unwrap_or(ops.len()) - } else { - 0 - }; - Some((start, next, close)) + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let close = ops + .iter() + .enumerate() + .skip(end.saturating_add(1)) + .find(|(_, op)| op.op.as_slice() == b"ET") + .map(|(index, _)| index.saturating_add(1)) + .unwrap_or(ops.len()); + // `None` is the next-line index: the close is after this paragraph's `ET`, not at the + // following line's `BT`. + Some((start, None, close)) } struct RewriteAnchor<'a> { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c9d371cc56..65a93ebf75 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,7 +1914,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] From 21b911ae4260e7ae41c532aa48a5855fafb49d62 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:28:55 +0200 Subject: [PATCH 36/82] M7: close a shared text object before the next paragraph (#2517) A partial rewrite put the paragraph marker around the shared ET, so the neighbour in that text object joined the paragraph and the next edit deleted it. --- ROADMAP.md | 2 +- crates/edit/src/tests.rs | 19 +++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 22 ++++++++++++++++++++-- docs/gaps.md | 8 +++++--- docs/roadmap.md | 3 ++- parity/acrobat-features.toml | 4 ++-- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 83cfd66532..e8f8c5a69d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,7 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. -- **2026-10-18 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. The extra marker is not added across two marked-content regions. Estimates unchanged. +- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged. - **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 81279270a9..c7f4787adc 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2963,6 +2963,25 @@ fn a_partial_rewrite_that_adds_a_row_keeps_the_neighbour() { } } +/// Both paragraphs share one text object, with a blank line between them. The paragraph +/// marker has to close before the neighbour, not after the shared `ET`. +#[test] +fn a_partial_rewrite_inside_one_text_object_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."], "save joined the neighbour"); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + /// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. /// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. #[test] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 557a86f1d6..5df2d9cd1d 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -647,6 +647,10 @@ fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option { /// append it after the stream). Closing at the next line's `BT` is too late when that line's /// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing /// inside the rewritten operators leaves a kept source row outside the marker. +/// +/// A neighbour in the same text object has no `BT` of its own. Closing after the shared `ET` +/// would take that neighbour into the marker. The marker then opens after `BT` and closes +/// before the neighbour's text, so `BT` and `ET` stay matched. fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option, usize)> { let (start, next) = paragraph_span(block, lines)?; if let Some(region) = shared_scope(block, lines) @@ -657,18 +661,32 @@ fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option return None; } } + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); let end = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; - let close = ops + let after_text = ops .iter() .enumerate() .skip(end.saturating_add(1)) .find(|(_, op)| op.op.as_slice() == b"ET") .map(|(index, _)| index.saturating_add(1)) .unwrap_or(ops.len()); + let neighbour = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min()) + .min(); + if let Some(op) = neighbour.filter(|op| *op < after_text) { + let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?; + // `BT` stays outside the marker. Both ends are inside the one text object. + if open > start { + return Some((open, None, op)); + } + } // `None` is the next-line index: the close is after this paragraph's `ET`, not at the // following line's `BT`. - Some((start, None, close)) + Some((start, None, after_text)) } struct RewriteAnchor<'a> { diff --git a/docs/gaps.md b/docs/gaps.md index 9457fd7595..4f35e651d1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,6 +5,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (gap 3: #2517 a partial rewrite inside one text object keeps the next paragraph; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -105,9 +106,10 @@ update the numbers. baseline is still replaced. A blank line stays a paragraph break. An explicit `TL` is the measured baseline gap, and growing a paragraph so its new row is one leading above the next one does not absorb that paragraph (#2517). A partial rewrite of the last paragraph keeps a - source row inside that paragraph, and does not close a following marked region early. A heading and smaller body lines (#1756), a - hanging-indent continuation (#2564), and cells in different columns (#1680, #2478) are still - grouped by the same geometry. Estimates are unchanged. + source row inside that paragraph, and does not close a following marked region early. Two + paragraphs that share one text object keep that boundary when the first is rewritten. A heading + and smaller body lines (#1756) and cells in different columns (#1680, #2478) are still grouped + by the same geometry. A row that a list item wraps onto stays with that item. Estimates are unchanged. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping diff --git a/docs/roadmap.md b/docs/roadmap.md index 184a1b7a37..bad3da9a63 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -9,7 +9,7 @@ > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M7 #2517: a partial rewrite inside one text object closes its marker before the next paragraph; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -266,6 +266,7 @@ Estimates unchanged. | 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | +| 2026-10-22 | minor | M7 #2517: a partial rewrite of two paragraphs that share one text object closes the marker before the neighbour, so a later edit does not delete it. Estimates unchanged. | | 2026-10-18 | minor | M7 #2517: a partial rewrite of the last paragraph closes its marker after the last `ET`, and does not close a following marked region early. Estimates unchanged. | | 2026-10-15 | minor | M7 #2517: a blank line between paragraphs stays a paragraph break, and growing the first paragraph does not absorb the next one. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 65a93ebf75..5ff2829e2f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] id = "edit.font-reuse-substitution" From 62af4f87cae57d1255fef91560d6393a8afb3e7e Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:07:36 +0200 Subject: [PATCH 37/82] M7: compile the paragraph fixture on current main (#2517) The paragraph-key helper has to carry reflow text, which a width change uses. ClosePath on an empty subpath is a match guard so the edit crate still passes clippy. --- crates/edit/src/images.rs | 8 +++----- crates/edit/src/text_regions/grouping.rs | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5180e76018..f66e7f24ee 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -186,11 +186,9 @@ impl ImageState { self.path.curve_to(self.point(x1, y1), end, end); } } - b"h" => { - // ClosePath with no subpath panics by the same contract (#3581). - if self.path.elements().last().is_some() { - self.path.close_path(); - } + // ClosePath with no subpath panics by the same contract (#3581). + b"h" if self.path.elements().last().is_some() => { + self.path.close_path(); } b"re" => { if let Some([x, y, w, h]) = op.nums::<4>() { diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index f866daef5e..df281eb922 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -235,7 +235,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and // letting it join is what made an edit of the row above delete this item and the ones // after it. - if starts_list_item(&row, lines) { + if starts_list_item(row, lines) { return false; } let Some(prev_row) = block.rows.last() else { return false }; From 59513aa8878d610d0d497ffb1b476af4ff9edd9c Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:49:46 +0200 Subject: [PATCH 38/82] M7: keep a preceding paragraph out of a shared text object's marker (#2517) A partial rewrite of the last paragraph opened the marker at the shared BT, so the paragraph before it was inside the region. --- crates/edit/src/tests.rs | 25 +++++++++++++++++++++++++ crates/edit/src/text_regions/styled.rs | 16 +++++++++++++++- parity/acrobat-features.toml | 4 ++-- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c7f4787adc..9cc8cf2177 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2982,6 +2982,31 @@ fn a_partial_rewrite_inside_one_text_object_keeps_the_neighbour() { } } +/// The last paragraph of a shared text object has no neighbour after it. The marker still has +/// to open after the preceding paragraph, not at the shared `BT`, or that paragraph joins it. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it() { + let src = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 1, "Neighbor stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + let neighbour = content.find("(Neighbor stays.)").expect("the kept row"); + assert!(open < neighbour, "the kept row is outside the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "save joined the preceding paragraph"); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Neighbor stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + } +} + /// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. /// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. #[test] diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 5df2d9cd1d..be7e627ba6 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -677,13 +677,27 @@ fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option .filter(|(index, _)| !belongs.contains(index)) .filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min()) .min(); + let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?; if let Some(op) = neighbour.filter(|op| *op < after_text) { - let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?; // `BT` stays outside the marker. Both ends are inside the one text object. if open > start { return Some((open, None, op)); } } + // The last paragraph of a shared text object has no neighbour after it. Opening at the + // shared `BT` would take the preceding paragraph into the marker. Open at this paragraph's + // first operator instead, and close before the shared `ET`, so both ends stay inside it. + let preceding = + lines.iter().enumerate().filter(|(index, _)| !belongs.contains(index)).any(|(_, line)| line.ops.iter().any(|op| *op > start && *op < open)); + if preceding { + if let Some(et) = after_text.checked_sub(1).filter(|index| ops.get(*index).is_some_and(|op| op.op.as_slice() == b"ET")) + && open > start + && open < et + { + return Some((open, None, et)); + } + return None; + } // `None` is the next-line index: the close is after this paragraph's `ET`, not at the // following line's `BT`. Some((start, None, after_text)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5ff2829e2f..a5a4fb3bec 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,8 +1914,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. When the rewritten paragraph is the last one in that text object, the marker opens after the preceding paragraph and closes before the shared ET. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] id = "edit.font-reuse-substitution" From b282ec26be9f8d6a897473871d950c8673f0e1c8 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:02:52 +0200 Subject: [PATCH 39/82] M7: check a shared text object through the tools (#2517) Editing either end of one text object, then saving and editing again, has to leave the other paragraph in place. The library tests did not drive text_edit and doc_save. --- crates/automation/tests/it/automation.rs | 40 ++++++++++++++++++++++++ crates/edit/src/tests.rs | 31 ++++++++++++++++++ parity/acrobat-features.toml | 2 +- 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 226796cfe4..942da54a95 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9612,3 +9612,43 @@ fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() { ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]); } + +/// Both ends of one text object, through open / text_edit / save / reopen / text_extract. +/// Editing the last paragraph must not take the one before it, and editing the first must +/// not take the one after it. Incremental and full saves. +#[test] +fn a_shared_text_object_keeps_both_paragraphs_through_tools() { + let dir = workdir("shared-text-object"); + let mut a = auto(&dir); + let body = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." })); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}"); + + std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true })); + let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}"); +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9cc8cf2177..7ba088722b 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3007,6 +3007,37 @@ fn a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_befo } } +/// Three paragraphs share one text object. Rewriting the middle one must leave the paragraph +/// before it and the paragraph after it outside the marker. +#[test] +fn a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj \ + T* T* (Mid stays.) Tj T* (Mid next.) Tj T* T* (End stays.) Tj T* (End next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!( + texts(&doc), + ["Alpha first row. Alpha next row.", "Mid stays. Mid next.", "End stays. End next."], + "each blank line is a paragraph break" + ); + text::replace_block(&mut doc, 0, 1, "Mid stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + let end = content.find("(End stays.)").expect("the following paragraph"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + assert!(open < end, "the following paragraph is inside the marker: {content}"); + let close = content[open..].find("EMC").map(|at| open + at).expect("close"); + assert!(close < end, "the marker closed after the following paragraph: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."]); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Mid stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Mid stays. Edited.", "End stays. End next."]); + } +} + /// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. /// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a5a4fb3bec..ca854bac90 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1914,7 +1914,7 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_paragraphs"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it", "crates/edit/src/tests.rs::a_blank_line_keeps_the_next_paragraph", "crates/edit/src/tests.rs::a_grown_paragraph_does_not_swallow_the_next_one", "crates/automation/tests/it/automation.rs::a_blank_line_stays_a_separate_paragraph_through_tools", "crates/automation/tests/it/automation.rs::paragraph_grouping_survives_save_and_reopen_through_tools", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_only_paragraph_stays_one_block", "crates/edit/src/tests.rs::a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region", "crates/edit/src/tests.rs::a_partial_rewrite_keeps_the_next_marked_region_closed", "crates/edit/src/tests.rs::a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it", "crates/edit/src/tests.rs::a_partial_rewrite_inside_one_text_object_keeps_the_neighbour", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it", "crates/edit/src/tests.rs::a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours", "crates/automation/tests/it/automation.rs::a_shared_text_object_keeps_both_paragraphs_through_tools", "crates/automation/tests/it/automation.rs::a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; a blank line (about twice the paragraph's own leading) stays a separate paragraph, and editing the first one leaves the next in place (#2517). An explicit TL is that leading. Without one, Td does not set it: three or more rows at one repeated step stay one paragraph, and a single wider gap stays a break. A rewrite marks the whole paragraph, including a row it leaves in the source, so adding a row does not absorb the next paragraph on a later edit. When the paragraph is the last text on the page, that marker closes after the paragraph's last ET, so a kept source row stays inside it. When the next paragraph shares that text object, the marker opens after BT and closes before the neighbour's text. When the rewritten paragraph is the last one in that text object, the marker opens after the preceding paragraph and closes before the shared ET. The extra marker is not added when the paragraph is already one marked region and the next line is in a different region. Hyphenated line ends are joined. A row opening with a list marker starts its own item, and the row a list item wraps onto continues it at its hanging indent, so editing one item never takes the items after it. Still open: a larger heading joined to smaller body lines (#1756) and cells in different columns (#1680, #2478). Two rows and no third step still cannot be told from a paragraph break when there is no TL." [[feature]] From dbeb9fa96307b02bebe33c32f7d8283eb45c5804 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:04:27 +0100 Subject: [PATCH 40/82] M7: move a paragraph instead of rewriting it to trigger a stale-number regroup wrap()'s own test for mixed exotic whitespace fed through it directly, bypassing the caller's CR/CRLF normalization, needed its expected value updated for #4199's keep-every-space design, landed on main after this branch's base. --- crates/automation/tests/it/automation.rs | 36 +++++++++++------------- crates/edit/src/text.rs | 3 ++ 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 942da54a95..cdef5dc5af 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8962,8 +8962,8 @@ fn text_box_and_callout_fill_and_border_through_tools() { } /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading -/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next -/// read groups the two as one paragraph. +/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves +/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. fn three_paragraph_pdf() -> Vec { use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use std::sync::Arc; @@ -9012,6 +9012,10 @@ fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() { /// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups /// the page so that paragraph no longer exists on its own, the next edit by that number is /// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone. +/// +/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or +/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry +/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can. #[test] fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { let dir = workdir("text-edit-stale-number"); @@ -9023,12 +9027,9 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo")); // Listing again here would renumber the page, so the first edit's result says what moved. - let first = ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); - assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}"); + // Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading. + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); + assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}"); // Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph. let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); @@ -9043,13 +9044,14 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie")); ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."})); let text = page_text(&mut a, doc).join(" "); - assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}"); + assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}"); std::fs::remove_dir_all(dir).unwrap(); } /// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow: /// the merged one holds the edited text too, but rewriting it by the edited paragraph's number -/// would drop the neighbour's text (#2516). +/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the +/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517). #[test] fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { let dir = workdir("text-edit-merged-number"); @@ -9057,11 +9059,7 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); - let first = ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}"); // Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused. @@ -9075,6 +9073,8 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { /// The editor background preview takes the same paragraph number as `text_edit`, so it hides the /// paragraph the number named in the listing and refuses a number an edit made stale (#2516). +/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked +/// precisely so it cannot merge this way (#2517). #[test] fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() { let dir = workdir("hide-text-block-stale-number"); @@ -9082,11 +9082,7 @@ fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrou let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); - ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); // Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie. let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 7bcff773a8..d815ee4282 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -218,6 +218,9 @@ mod tests { assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]); assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]); assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]); + // `wrap` treats every whitespace character, including `\r`, as a separator to keep + // (#4199); the caller turns `\r\n` and `\r` into `\n` before calling it. + assert_eq!(wrapped(" \tA B\r\nC ", 3.0), [" A", "B ", "C "]); } #[test] From 8a78fb21cfa0c643fadea9fa8118106c3488198d Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:33:11 +0100 Subject: [PATCH 41/82] M3: translate the menu bar's own overflow sentinel to a glyph every font has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit craft_menu writes "⋯" (U+22EF) itself for the menu of titles that don't fit, through the app's own label callback. No font this app ships or falls back to has that glyph, and egui 0.37's MissingGlyphPolicy::Panic crashes on it as soon as the bar is narrow enough to need it: every editing test that narrows the window while a paragraph is open hit this. The ordinary ellipsis the rest of the app already uses everywhere else is available and reads the same. --- crates/ui-egui/src/menus.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b6dcc67858..49f2aeba1a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -256,9 +256,13 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { - // The bundled UI fonts do not all cover the menu's mathematical ellipsis. + // craft_menu's bar writes "⋯" (U+22EF, MIDLINE HORIZONTAL ELLIPSIS) itself, for the menu of + // titles that don't fit: a sentinel to translate, not an English phrase, and the only one of + // its four reserved labels no font this app ships or falls back to has a glyph for. + // MissingGlyphPolicy::Panic (egui 0.37) crashes on it. The ordinary ellipsis every other + // truncated label in this app already uses is in the bundled faces. if english == "\u{22ef}" { - return "...".to_owned(); + return "…".to_owned(); } tl!(english).to_owned() } @@ -662,6 +666,22 @@ mod tests { bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) } + /// The bar's own overflow-menu sentinel is not rendered as typed: no font this app ships or + /// falls back to has U+22EF, and egui 0.37's `MissingGlyphPolicy::Panic` crashes on it when + /// the menu bar is narrow enough to need it. The editing tests that narrow the window while a + /// paragraph is open reproduce this end to end. The other three reserved labels are plain + /// ASCII, so untranslated is safe for them. + #[test] + fn the_overflow_menu_sentinel_is_not_passed_through_untranslated() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let translated = translate("⋯"); + assert_ne!(translated, "⋯", "no installed font has this glyph"); + assert_eq!(translated, "…", "the ordinary ellipsis the rest of the app already uses"); + for label in ["Menu", "Scroll menu up", "Scroll menu down"] { + assert_eq!(translate(label), label, "plain ASCII reserved labels need no substitution"); + } + } + #[test] fn every_menu_command_is_listed_once_with_its_shortcut() { let (app, ctx) = app(&["a.pdf"]); From bb7186d87c71c05228f879e8ba1cfd5910484583 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:26:05 +0100 Subject: [PATCH 42/82] M7: restore the exact gap a wrap consumed, not a guessed space (#2517) wrap() already dropped the whole gap at a wrap point (#4199); rejoining it always guessed a single space back, so two or more typed spaces silently became one, and a Chinese/Japanese word split (which has no space of its own) could gain a spurious one. wrap() now records how many separator characters it consumed at each wrap (0 for a CJK split); the /PCSoft marker carries it as /N (a file saved before this, or by another writer, reads as 1); reflow_text and the mixed-style row editor insert exactly that many spaces back instead of assuming one. --- crates/edit/src/tests.rs | 53 +++++++++ crates/edit/src/text.rs | 21 +++- crates/edit/src/text/blocks.rs | 21 +++- crates/edit/src/text/interp.rs | 21 ++++ crates/edit/src/text/lines.rs | 2 + crates/edit/src/text/rewrite.rs | 29 +++-- crates/edit/src/text_regions/grouping.rs | 33 +++--- crates/edit/src/text_regions/styled.rs | 137 +++++++++++++++-------- 8 files changed, 240 insertions(+), 77 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7ba088722b..5491dfca43 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3283,6 +3283,59 @@ fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour"); } +/// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same +/// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap +/// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back +/// must not quietly turn two typed spaces into one. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space() { + let mut doc = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap().rect; + let wide_width = wide[2] - wide[0]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "the second typed space was lost: {:?}", rewidened.editor_text); +} + +/// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those +/// pieces join without a space"). Narrowing a paragraph until it wraps inside such a word, then +/// widening it back, must restore the two pieces exactly as they were, not insert a space between +/// them. Checked on both the single-style rewrite path and the row-editor (mixed-style) path. +#[test] +fn narrowing_then_widening_a_japanese_word_adds_no_space() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping narrowing_then_widening_a_japanese_word_adds_no_space: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章です"; + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap(); + let wide = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + let wide_width = wide.rect[2] - wide.rect[0]; + + // Narrow enough to force a wrap inside the Japanese word, not just between the two rows. + // A width-only change must pass `reflow_text` explicitly, as the editor UI does: `None` + // falls back to the paragraph's space-joined display text and would not keep a typed break. + text::rewrite_block(&mut doc, 0, 0, Some(&wide.reflow_text), &text::BlockStyle { width: Some(60.0), ..Default::default() }).unwrap(); + let narrow = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert!(narrow.rows.len() > draft.matches('\n').count() + 1, "the narrow box did not wrap: {:?}", narrow.editor_text); + assert_eq!( + narrow.editor_text.replace('\n', ""), + draft.replace('\n', ""), + "wrapping must not add or drop characters: {:?}", + narrow.editor_text + ); + + text::rewrite_block(&mut doc, 0, 0, Some(&narrow.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, draft, "a Japanese word split gained a space it never had: {:?}", rewidened.editor_text); + } +} + /// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a /// short line that had room. #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index d815ee4282..b366e36f4d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -122,6 +122,10 @@ pub struct TextLine { soft_break: bool, /// This line starts after a break a rewrite kept on purpose (`/PCHard`). hard_break: bool, + /// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a + /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none + /// (#2529). Meaningless otherwise. + soft_gap: usize, } impl TextLine { @@ -210,7 +214,7 @@ mod tests { } fn wrapped(text: &str, width: f64) -> Vec { - wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _)| line).collect() + wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect() } #[test] @@ -226,8 +230,19 @@ mod tests { #[test] fn a_wrap_is_marked_soft_and_a_newline_is_not() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false), ("1".into(), true)]); - assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false), ("Two".into(), false)]); + assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]); + assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]); + } + + /// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not + /// assumed to be one space; a Chinese/Japanese word split, which has no space of its own, + /// is recorded as none. + #[test] + fn a_wrap_records_how_many_spaces_it_consumed() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]); + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]); + assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]); } /// #4199: a run of spaces stays a run, before, between and after words; the spaces where a diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 2785f87676..d6dcc585f1 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -340,13 +340,18 @@ pub(crate) fn paragraph_separator(c: char) -> bool { /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so /// a run of spaces typed between, before or after words stays (#4199); only the spaces where a -/// row wraps are dropped. The bool is true when the line continues the previous one because the -/// next word did not fit, rather than because of a newline. -pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool)> { +/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact +/// count instead of guessing one, #2529). The bool is true when the line continues the previous +/// one because the next word did not fit, rather than because of a newline; the number is how +/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split, +/// which has no space of its own; meaningless when the bool is false). +pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> { let mut out = Vec::new(); for row in text.split('\n') { let mut line = String::new(); let mut soft = false; + // The gap consumed right before `line` started: 0 until a wrap sets it. + let mut wrap_gap = 0usize; let start = out.len(); // The separators read since the last word: each one boundary between `split` pieces. let mut gap = 0usize; @@ -357,13 +362,17 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec if word.is_empty() { continue; } - let spaces = " ".repeat(std::mem::take(&mut gap)); + let gap_here = std::mem::take(&mut gap); + let spaces = " ".repeat(gap_here); for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") }; // A row holding only the spaces that indent it doesn't wrap before its first word. if line.chars().any(|c| c != ' ') && advance(&candidate) > width { - out.push((std::mem::take(&mut line), soft)); + out.push((std::mem::take(&mut line), soft, wrap_gap)); soft = true; + // A mid-word piece (i > 0) had no separator of its own; the first piece of a + // word had exactly the separators just taken. + wrap_gap = if i == 0 { gap_here } else { 0 }; line = piece.to_string(); } else { line = candidate; @@ -372,7 +381,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec } line.push_str(&" ".repeat(gap)); if !line.is_empty() || out.len() == start { - out.push((line, soft)); + out.push((line, soft, wrap_gap)); } } out diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index e3e4bf26b3..73ca1b1424 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -304,6 +304,11 @@ pub(super) struct Shown { /// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line. pub(super) soft_break: bool, pub(super) hard_break: bool, + /// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0 + /// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a + /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), + /// matching the single space a rejoin always used before #2529. + pub(super) soft_gap: usize, } impl Shown { @@ -554,6 +559,7 @@ struct TextRunState<'a> { out: &'a mut Vec, soft_break: bool, hard_break: bool, + soft_gap: usize, } fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { @@ -752,6 +758,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit actual_text: false, soft_break: state.soft_break, hard_break: state.hard_break, + soft_gap: state.soft_gap, }); Ok(()) } @@ -780,6 +787,10 @@ pub(super) fn interpret( // before the next line. Neither name is a paragraph region, so it does not change scope. let mut soft_break = false; let mut hard_break = false; + // `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft` + // with no properties dict (a file saved before this was tracked, or by another writer) reads + // as 1, the single space a rejoin always used before #2529. + let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing @@ -788,6 +799,14 @@ pub(super) fn interpret( if op.name(0) == Some(b"PCSoft") { soft_break = true; hard_break = false; + soft_gap = op + .operands + .get(1) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"N")) + .and_then(Object::as_int) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(1); } else if op.name(0) == Some(b"PCHard") { hard_break = true; soft_break = false; @@ -908,6 +927,7 @@ pub(super) fn interpret( out: &mut out, soft_break, hard_break, + soft_gap, }; show_text(op, i, &mut state)?; // A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break @@ -915,6 +935,7 @@ pub(super) fn interpret( if out.len() > before { soft_break = false; hard_break = false; + soft_gap = 1; } } _ => {} diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 78aec5090f..f811f34ea6 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -282,6 +282,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> runs: vec![(s.text.clone(), s.ink.len(), false)], soft_break: s.soft_break, hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -398,6 +399,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream runs: vec![(s.text.clone(), s.ink.len(), false)], soft_break: s.soft_break, hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 9589fad226..4b6944deeb 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -578,27 +578,38 @@ impl LineWriter<'_> { } } - /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. `wrap`: whether + /// this line continues the previous one because a word did not fit, and, when it does, how + /// many separator characters the wrap consumed (0 for a Chinese/Japanese word split). fn write_line( &self, index: usize, line: &str, - soft: bool, + wrap: (bool, usize), last: bool, cursor: &mut LineCursor, ops: &mut Vec, ) -> Result<(f64, f64, f64), EditError> { let n = pdfcraft_content::num; + let (soft, gap) = wrap; let body = line.trim_matches(' '); let interior = body.matches(' ').count(); let tw = self.stretch(body, last); let (shown, visible) = self.shown(line, body, tw); let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; if index > 0 { - // Inside this text object, so the marker starts and ends here. `/PCSoft` rejoins - // when the box widens. `/PCHard` is a newline that was already in the text. - let name = if soft { "PCSoft" } else { "PCHard" }; - ops.push(Op::new("BMC", vec![Object::name(name)])); + // Inside this text object, so the marker starts and ends here. `/PCHard` is a newline + // that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the + // exact number of separator characters the wrap consumed (0 for a Chinese/Japanese + // word split), so a rejoin restores what was actually there instead of guessing a + // single space and silently dropping the rest of a typed run (#2529). + if soft { + let mut properties = Dict::new(); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } ops.push(Op::new("EMC", vec![])); } if index > 0 || dx != 0.0 { @@ -648,11 +659,11 @@ impl LineWriter<'_> { } /// Writes every wrapped line, returning their underlines. - fn write_all(&self, wrapped: &[(String, bool)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { wrapped .iter() .enumerate() - .map(|(i, (line, soft))| self.write_line(i, line, *soft, i + 1 == wrapped.len(), cursor, ops)) + .map(|(i, (line, soft, gap))| self.write_line(i, line, (*soft, *gap), i + 1 == wrapped.len(), cursor, ops)) .collect() } } @@ -672,7 +683,7 @@ struct BlockPlan<'a> { underline: bool, } -fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool)], show_font: String) -> Result, EditError> { +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result, EditError> { let n = pdfcraft_content::num; let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index df281eb922..26f9343397 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -365,14 +365,17 @@ fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { let mut out = String::new(); for (index, part) in parts.iter().enumerate() { if index > 0 { - if break_is_soft(block, lines, index) { - if out.ends_with('-') { + match break_gap(block, lines, index) { + Some(_) if out.ends_with('-') => { out.pop(); - } else if !out.ends_with(char::is_whitespace) { - out.push(' '); } - } else { - out.push('\n'); + // `gap` is the exact number of separator characters the wrap consumed (0 for a + // Chinese/Japanese word split, which has no space of its own) when the marker or + // the source recorded it; a line already ending in whitespace is left alone so a + // row whose own text kept a trailing space isn't doubled. + Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)), + Some(_) => {} + None => out.push('\n'), } } out.push_str(part); @@ -380,20 +383,24 @@ fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { out } -fn break_is_soft(block: &TextBlock, lines: &[TextLine], row_index: usize) -> bool { - let Some(line) = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i)) else { return false }; +/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap` +/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this +/// app did not mark (a file written elsewhere, or an older save), a single space when the +/// geometry looks like an ordinary wrapped body line (`#2529`). +fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option { + let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?; if line.hard_break { - return false; + return None; } if line.soft_break { - return true; + return Some(line.soft_gap); } - let Some(prev) = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines)) else { return false }; - let Some(next) = block.rows.get(row_index).and_then(|row| row_rect(row, lines)) else { return false }; + let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?; + let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?; let size = line.size.max(1.0); let slack = block.rect[2] - prev[2]; let width = prev[2] - prev[0]; // Short rows define the right edge by their own letters. A body line that fills the // measure leaves almost no room for the next word. - slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack + (slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1) } diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index be7e627ba6..ea18a2a21a 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -405,12 +405,17 @@ fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, -) -> Result<(StyledRows, RowRanges, Vec), EditError> { +) -> Result<(StyledRows, RowRanges, Vec, Vec), EditError> { let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); // True when this row starts because the previous word did not fit, not because of a newline. let mut row_soft = Vec::new(); let mut next_soft = false; + // How many separator characters a wrap trimmed off the row that just ended, for the row + // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // the row it belongs to is soft). Mirrors `next_soft`. + let mut row_gap = Vec::new(); + let mut next_gap = 0usize; let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; @@ -424,6 +429,8 @@ fn wrap_styled_rows( if item.0 == '\n' { row_soft.push(next_soft); next_soft = false; + row_gap.push(next_gap); + next_gap = 0; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -444,11 +451,15 @@ fn wrap_styled_rows( let word = chars.get(at..end).unwrap_or_default(); let word_width = measure(word)?; if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + let mut trimmed = 0usize; while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { row.pop(); + trimmed += 1; } row_soft.push(next_soft); next_soft = true; + row_gap.push(next_gap); + next_gap = trimmed; row_ranges.push(row_start..row_start + row.len()); rows.push(std::mem::take(&mut row)); row_width = 0.0; @@ -460,10 +471,11 @@ fn wrap_styled_rows( } if !row.is_empty() { row_soft.push(next_soft); + row_gap.push(next_gap); row_ranges.push(row_start..row_start + row.len()); rows.push(row); } - Ok((rows, row_ranges, row_soft)) + Ok((rows, row_ranges, row_soft, row_gap)) } fn apply_range_styles<'a>( @@ -745,6 +757,78 @@ fn rewrite_anchor<'a>( RewriteAnchor { lead, drop, start, split } } +/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when +/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph +/// cannot be mistaken for a neighbour by geometry alone (#2517). +fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) { + if !marked { + return; + } + let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() }; + block_ops.push(Op::new(op, operands)); +} + +/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in +/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of +/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph +/// region, so the lines stay in one scope. +fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { + if row_soft.get(row_index).copied().unwrap_or(false) { + let mut properties = Dict::new(); + let gap = row_gap.get(row_index).copied().unwrap_or(1); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + block_ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + block_ops.push(Op::new("EMC", vec![])); +} + +/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` +/// marker opened at its start and closed at its `next` line or, when nothing follows, after the +/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or +/// beyond `ops.len()`, since `splice`'s callback never visits that index). +fn splice_with_marker( + pieces: &Pieces, + ops: &[Op], + marker: Option<(usize, Option, usize)>, + at: usize, + mut block_ops: Vec, + drop: &std::collections::HashSet, +) -> Vec> { + let data = splice(pieces, ops, |i| { + let mut insert = Vec::new(); + if let Some((start_at, next, close)) = marker { + if i == start_at { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if next.is_none() && i == close { + insert.push(Op::new("EMC", vec![])); + } + if next == Some(i) { + insert.push(Op::new("EMC", vec![])); + } + } + if i == at { + insert.append(&mut block_ops); + } + (insert, !drop.contains(&i)) + }); + if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { + return data; + } + let mut data = data; + if let Some(last) = data.last_mut() { + if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { + last.push(b'\n'); + } + // `splice` never visits `ops.len()`, so a close after the final operator is appended. + last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); + } + data +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -856,7 +940,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let (rows, row_ranges, row_soft) = wrap_styled_rows(&chars, width, measure)?; + let (rows, row_ranges, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -895,9 +979,7 @@ pub(crate) fn rewrite_styled_block( // scope so the neighbour stays out (#2517). A row left in the source stream cannot share // that marker, so it is omitted and geometry groups those rows as before. let marked = kept.iter().all(|row| !*row); - if marked { - block_ops.push(Op::new("BMC", vec![Object::name("P")])); - } + push_whole_paragraph_marker(&mut block_ops, marked, "BMC"); let crop = p.crop(doc); let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); let source_leading = block @@ -926,12 +1008,7 @@ pub(crate) fn rewrite_styled_block( continue; } if !row.is_empty() && row_index > 0 { - // An empty marker between lines. `/PCSoft` is a wrap and rejoins when the box - // widens. `/PCHard` is a newline that was already in the text. Neither is a - // paragraph region, so the lines stay in one scope. - let name = if row_soft.get(row_index).copied().unwrap_or(false) { "PCSoft" } else { "PCHard" }; - block_ops.push(Op::new("BMC", vec![Object::name(name)])); - block_ops.push(Op::new("EMC", vec![])); + push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index); } let w = measured.get(row_index).copied().unwrap_or(0.0); let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); @@ -1076,9 +1153,7 @@ pub(crate) fn rewrite_styled_block( style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) }; } - if marked { - block_ops.push(Op::new("EMC", vec![])); - } + push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); block_ops.push(Op::new("Q", vec![])); block_ops.extend(after.ops()); if split { @@ -1091,37 +1166,7 @@ pub(crate) fn rewrite_styled_block( // safe. The close is not part of `block_ops`: that vector can end inside a text object // when the rewrite splits one, and a kept row's operators come later in the stream. let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; - let data = splice(&pieces, &ops, |i| { - let mut insert = Vec::new(); - if let Some((start_at, next, close)) = marker { - if i == start_at { - insert.push(Op::new("BMC", vec![Object::name("P")])); - } - if next.is_none() && i == close { - insert.push(Op::new("EMC", vec![])); - } - if next == Some(i) { - insert.push(Op::new("EMC", vec![])); - } - } - if i == at { - insert.append(&mut block_ops); - } - (insert, !drop.contains(&i)) - }); - let data = if marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { - let mut data = data; - if let Some(last) = data.last_mut() { - if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { - last.push(b'\n'); - } - // `splice` never visits `ops.len()`, so a close after the final operator is appended. - last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); - } - data - } else { - data - }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { From 55a933800b94b89c0caae001ec48609cd5f70256 Mon Sep 17 00:00:00 2001 From: ihsan mokhlisse <16743959+ihsanmokhlisse@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:42:20 +0100 Subject: [PATCH 43/82] M7: keep a typed space run through a saved width change (#2517) The wrap's space count has to survive an incremental save and a full rewrite, and a width-only text_edit has to reflow with that count. A recorded editor session narrows the box and widens it again. --- ROADMAP.md | 2 ++ crates/automation/src/dispatch.rs | 14 ++++++-- crates/automation/tests/it/automation.rs | 27 +++++++++++++++ crates/edit/src/tests.rs | 34 +++++++++++++------ .../tests/play/narrow-widen-double-space.json | 15 ++++++++ docs/roadmap.md | 2 ++ parity/acrobat-features.toml | 4 +-- 7 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/tests/play/narrow-widen-double-space.json diff --git a/ROADMAP.md b/ROADMAP.md index e8f8c5a69d..858ab719c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -201,6 +202,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index b136076bc8..b56b04d92f 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -538,7 +538,17 @@ impl Automation { }; let index = self.paragraph_index(id, page, k, &blocks)?; let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let width = a.opt_num("width")?; + // A width change with no new text is the box-edge drag. `reflow_text` rejoins a + // wrap with the spaces it consumed and keeps a newline that was typed. `text` + // joins every row with one space, so that drag would turn two typed spaces into + // one (#2529). A move or a formatting change still uses `text`: those edits are + // what the stale-paragraph checks regroup with, and they are not a reflow. + let text = match a.opt_str("text")? { + Some(text) => text.to_owned(), + None if width.is_some() => block.reflow_text.clone(), + None => block.text, + }; let mut style = pdfcraft_engine::BlockStyle { ranges: parse_style_ranges(a.get("ranges"))?, resource: a.opt_str("font_resource")?.map(str::to_owned), @@ -548,7 +558,7 @@ impl Automation { line_spacing: a.opt_num("line_spacing")?, char_spacing: a.opt_num("char_spacing")?, scale: a.opt_num("scale")?, - width: a.opt_num("width")?, + width, ..Default::default() }; let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index cdef5dc5af..3772f2fb70 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -9496,6 +9496,33 @@ fn a_blank_line_stays_a_separate_paragraph_through_tools() { assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); } +/// #2529, through the same tools as a width drag. The typed double space falls on the wrap. +/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite +/// both still have both spaces after the box is widened again. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() { + let dir = workdir("double-space-width"); + std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 })); + let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]; + assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}"); + for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] { + ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full })); + let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 })); + let wide_name = format!("wide-{name}"); + ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full })); + let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}"); + } +} + fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec { ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"] .as_array() diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 5491dfca43..b601db5654 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3286,18 +3286,32 @@ fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { /// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same /// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap /// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back -/// must not quietly turn two typed spaces into one. +/// must not quietly turn two typed spaces into one. The count is stored on the wrap marker, so +/// it has to survive an incremental save and a full rewrite, not only the in-memory rewrite. #[test] fn narrowing_then_widening_keeps_a_typed_double_space() { - let mut doc = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); - let wide = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap().rect; - let wide_width = wide[2] - wide[0]; - text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); - let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); - assert_eq!(block.editor_text, "One\nTwo", "the narrow box did not wrap between the two typed spaces: {}", block.editor_text); - text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); - let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); - assert_eq!(rewidened.editor_text, "One Two", "the second typed space was lost: {:?}", rewidened.editor_text); + let fresh = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide_width = { + let wide = text::text_blocks(&fresh, 0).unwrap().into_iter().next().unwrap().rect; + wide[2] - wide[0] + }; + for (save, reopen_as) in + [("incremental", reopen(&fresh)), ("full", Document::open(Arc::new(write_full(&fresh, &SaveOptions::default()).unwrap())).unwrap())] + { + let mut doc = reopen_as; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "{save}: the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + assert_eq!(block.reflow_text, "One Two", "{save}: the saved wrap did not remember both spaces: {:?}", block.reflow_text); + let mut doc = doc; + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "{save}: the second typed space was lost: {:?}", rewidened.editor_text); + } } /// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those diff --git a/crates/ui-egui/tests/play/narrow-widen-double-space.json b/crates/ui-egui/tests/play/narrow-widen-double-space.json new file mode 100644 index 0000000000..15fe835837 --- /dev/null +++ b/crates/ui-egui/tests/play/narrow-widen-double-space.json @@ -0,0 +1,15 @@ +{ + "name": "narrow then widen keeps two typed spaces", + "content": "BT /F1 24 Tf 72 700 Td (One Two) Tj ET", + "steps": [ + {"command": {"id": "edit.edit_text"}}, + {"click_page": {"x": 100, "y": 700}}, + {"expect_editor": {"contains": "One", "text": "One Two"}}, + {"key": {"key": "Enter", "modifiers": ["command"]}}, + {"drag_edge": {"contains": "One", "dx": -220}}, + {"expect_editor": {"contains": "One", "text": "One\nTwo"}}, + {"drag_edge": {"contains": "One", "dx": 360}}, + {"expect_editor": {"contains": "One", "text": "One Two"}}, + {"shot": {"name": "narrow-widen-double-space"}} + ] +} diff --git a/docs/roadmap.md b/docs/roadmap.md index bad3da9a63..17594ee766 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -256,6 +257,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ca854bac90..056cb1bfe7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1902,8 +1902,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools"] -notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" +evidence = ["crates/edit/src/tests.rs::paragraphs_move_and_rewrap_to_a_new_width", "crates/edit/src/tests.rs::a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays", "crates/edit/src/tests.rs::an_unmarked_full_line_rejoins_and_a_short_break_stays", "crates/edit/src/tests.rs::narrowing_then_widening_keeps_a_typed_double_space", "crates/edit/src/tests.rs::narrowing_then_widening_a_japanese_word_adds_no_space", "crates/ui-egui/tests/it/editing.rs::dragging_a_paragraph_moves_it_and_its_edge_rewraps_it", "crates/ui-egui/tests/it/editing.rs::a_paragraph_rewraps_from_the_outer_half_of_its_edge_handles", "crates/ui-egui/tests/it/editing.rs::a_quick_flick_on_either_edge_rewraps_the_paragraph", "crates/ui-egui/tests/it/editing.rs::an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys", "crates/ui-egui/tests/it/editing.rs::arrow_keys_nudge_a_selected_image_instead_of_turning_the_page", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/automation/tests/it/automation.rs::narrowing_then_widening_keeps_a_typed_double_space_through_tools", "crates/ui-egui/tests/play.rs::play_sessions_fetch_paragraph_bugs"] +notes = "existing paragraphs: drag the box to move it, drag the handle on its left or right edge to rewrap it to a new width (upright pages), including the half of the handle outside the box, which started an area selection instead (#2276); a wrap rejoins when the box is widened, with the number of spaces that wrap consumed (a typed run stays a run; a Chinese or Japanese word split adds none) and that count survives an incremental save and a full rewrite (#2529), and a newline that was typed stays; an unmarked line that fills the measure rejoins, a short line does not; the move is applied in page space through the paragraph's CTM, in its own q … Q; a click still opens it for typing (#40); once open, its frame grabs the box (click to select, drag to move) while the text keeps the caret, and the arrow keys nudge the selected box or image a point, Shift ten points (#1520)" [[feature]] id = "edit.paragraph-detection" From 5a86f35210ca98adb23ba53ff9d0871593b0cfdb Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:30:21 +1100 Subject: [PATCH 44/82] fix(edit, ui): alignments follow the right rows; Format by keyboard keeps the box Four holes CodeRabbit found on 648dd428: - alignment_frame framed a one-row paragraph when any alignment entry was set, including one past the draft's rows, and then moved rows with no alignment of their own to the column's edge. It now takes the draft and frames only when every draft row is aligned, by its own alignment or the paragraph's. - remap could not tell which of two same paragraphs an edit took ("A\nA" to "A"), and kept the deleted one's alignment. remap_at takes the caret where the edit ended, and the typing path (follow_typing, both editors) passes it from the box's state. The caret only chooses between readings that fit both texts. - Both editors judged a focus loss by where a click landed, so focusing a Format text control by keyboard or a screen reader (no pointer) closed the box before the alignment was chosen. They now also judge by where the keyboard went (focus_pos): focus outside the document view, or on the added-text box's own buttons, keeps the box open as a click there does. Each change has a test seen failing before it. --- crates/edit/src/paragraph_align.rs | 43 +++++++++++++- crates/edit/src/tests.rs | 16 ++++++ crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/grouping.rs | 17 +++--- crates/ui-egui/src/tools/content_ui.rs | 11 ++-- crates/ui-egui/src/tools/edit_text_ui.rs | 21 +++++-- crates/ui-egui/tests/it/content.rs | 71 ++++++++++++++++++++++++ crates/ui-egui/tests/it/editing.rs | 33 +++++++++++ 8 files changed, 193 insertions(+), 21 deletions(-) diff --git a/crates/edit/src/paragraph_align.rs b/crates/edit/src/paragraph_align.rs index cf2023e615..8fb0a4c5e4 100644 --- a/crates/edit/src/paragraph_align.rs +++ b/crates/edit/src/paragraph_align.rs @@ -69,13 +69,33 @@ fn trimmed(mut aligns: Vec>) -> Vec> { /// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or /// after a centred paragraph makes centred ones). pub fn remap(old: &str, new: &str, aligns: &[Option]) -> Vec> { + remap_at(old, new, aligns, None) +} + +/// [`remap`] for an edit that left the caret at byte `caret` of `new`. Repeated paragraphs can +/// make where the edit was ambiguous ("A\nA" to "A" deletes either one); the caret, at the edit's +/// end, says which. It only chooses between readings that fit the texts, so a caret away from the +/// edit never makes it an edit of other paragraphs. +pub fn remap_at(old: &str, new: &str, aligns: &[Option], caret: Option) -> Vec> { if aligns.is_empty() || old == new { return aligns.to_vec(); } let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect()); let shortest = o.len().min(n.len()); - let prefix = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest); - let suffix = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest - prefix); + let same_start = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest); + let same_end = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest); + let fewest_kept = shortest - same_start; + let (prefix, suffix) = match caret { + Some(at) if fewest_kept <= same_end => { + // The edit's paragraphs of `new` end before the caret's paragraph when the caret starts + // one, and with it otherwise. + let starts = at == 0 || at.checked_sub(1).and_then(|i| new.as_bytes().get(i)) == Some(&b'\n'); + let end = paragraph_at(new, at).saturating_add(usize::from(!starts)); + let suffix = n.len().saturating_sub(end).clamp(fewest_kept, same_end); + (shortest - suffix, suffix) + } + _ => (same_start, same_end.min(shortest - same_start)), + }; let at = |i: usize| aligns.get(i).copied().flatten(); let inserted_only = o.len() - prefix - suffix == 0; let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) }; @@ -132,4 +152,23 @@ mod tests { assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]); assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]); } + + /// Repeated rows make the edit's place ambiguous: "A\nA" to "A" deletes either row. The caret, + /// where the edit ended, settles which (CodeRabbit on 648dd428). + #[test] + fn the_caret_says_which_of_two_same_rows_an_edit_took() { + let aligns = [Some(Center), Some(Right)]; + assert_eq!(remap_at("A\nA", "A", &aligns, Some(0)), [Some(Right)], "the first row and its break deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, Some(1)), [Some(Center)], "the break and the second row deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, None), [Some(Center)], "no caret: as before"); + // Enter at the start of the first of two same rows: the new row is before it. + assert_eq!(remap_at("A\nA", "\nA\nA", &aligns, Some(1)), [Some(Center), Some(Center), Some(Right)]); + // Enter at the end of the second: the new row follows it. + assert_eq!(remap_at("A\nA", "A\nA\n", &aligns, Some(4)), [Some(Center), Some(Right), Some(Right)]); + // A caret away from the edit never makes it an edit of other rows. + assert_eq!(remap_at("A\nB\nC", "A\nB\nCx", &[Some(Center), Some(Right), None], Some(0)), [Some(Center), Some(Right)]); + // A caret past the end, or not on a character boundary, is no panic. + assert_eq!(remap_at("A\nA", "A", &aligns, Some(99)), [Some(Center)]); + assert_eq!(remap_at("é\né", "é", &aligns, Some(1)), [Some(Center)]); + } } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 7691039f24..f74bb02921 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -7255,6 +7255,22 @@ fn a_justified_paragraph_from_the_editor_fills_its_rows() { assert!(row("end")[2] < right - 20.0, "the last row isn't"); } +/// An alignment for a row the draft doesn't have names no row: a one-row title given one for a +/// second row only stays where it is, not moved to its column's edge. Nor does a row of the draft +/// with no alignment of its own move because another row has one (CodeRabbit on 648dd428). +#[test] +fn a_title_moves_to_its_column_only_for_an_alignment_of_its_own() { + let page = "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET"; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let before = title(&text_page(page)); + for draft in ["Title", "Title\nSub"] { + let mut doc = text_page(page); + let style = text::BlockStyle { aligns: vec![None, Some(added::Align::Right)], ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).unwrap(); + assert!((title(&doc)[0] - before[0]).abs() < 0.5, "{draft:?}: the title stays put, {:?} vs {before:?}", title(&doc)); + } +} + /// A column whose rows are several runs (a second style on the same baseline) reaches the right /// edge of its whole rows, not of the run at the left edge: a title wider than that first run is /// still held by the column and aligns to it (CodeRabbit on b9940d7d). diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 3f436d7b24..97712858ad 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -766,7 +766,7 @@ pub(crate) fn rewrite_block_from( // An alignment for a one-row paragraph is laid out across the column it sits in, as a // paragraph of that width moved to its left edge. let framed; - let style = match alignment_frame(&lines, &blocks, &b, style, text.is_some_and(|t| t != b.editor_text)) { + let style = match alignment_frame(&lines, &blocks, &b, style, text.unwrap_or(&b.editor_text)) { Some([left, right]) => { framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; &framed diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 3a16e4f7c0..85266c7bcc 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -50,15 +50,12 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { /// to align in: left-aligning a title centred on the page left it where it was, and since a single /// row grows to the page's margin, centring it again moved it right each time. `None` when no /// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of -/// its own, or when its text changes and no column holds it (`text_changed`). -pub(crate) fn alignment_frame( - lines: &[TextLine], - blocks: &[TextBlock], - block: &TextBlock, - style: &BlockStyle, - text_changed: bool, -) -> Option<[f64; 2]> { - let asked = style.align.is_some() || style.aligns.iter().any(Option::is_some); +/// its own, or when its `draft` text changes and no column holds it. +pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle, draft: &str) -> Option<[f64; 2]> { + // Every row of the draft is aligned, by its own alignment or the paragraph's: moving the + // paragraph to its column's edge would move a row that has none. Entries past the draft's + // rows name no row. + let asked = (0..crate::paragraph_align::count(draft)).all(|row| style.aligns.get(row).copied().flatten().or(style.align).is_some()); if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { return None; } @@ -88,7 +85,7 @@ pub(crate) fn alignment_frame( // the row keeps the width it would have had without the alignment. match frame { Some(frame) => Some(frame), - None if text_changed => None, + None if draft != block.editor_text => None, None => Some(own), } } diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 877edb6ae2..d7b4bd8cca 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -619,8 +619,9 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Clicks outside the document (the Format text panel) keep the text open, as they keep a // paragraph being edited open. // Where the click that took the focus was: the pointer itself may be gone by then (a touch, a - // pen lifted away). - let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // pen lifted away). The keyboard moving there (Tab, a screen reader) keeps it open too. + let away = |p: egui::Pos2| !view.viewport_rect().contains(p); + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || crate::edit_text_ui::focus_pos(ctx).is_some_and(away); // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). @@ -700,8 +701,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad }) .response .rect; - // Pressing a button takes the focus from the text: that is not a click away. - let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); + // Pressing a button, or moving the keyboard to one, takes the focus from the text: + // that is not a click away. + let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)) + || crate::edit_text_ui::focus_pos(ui.ctx()).is_some_and(|p| buttons.contains(p)); cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); commit = done || resp.lost_focus() && !on_buttons && !outside && !view.objects.hold_editor; }); diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 45fee6cf36..f5ba4e0b67 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -155,6 +155,10 @@ pub(crate) fn follow_typing( aligns: &mut [&mut Vec>], ) { let now: Vec> = aligns.iter().map(|a| a.to_vec()).collect(); + // Where the edit left the caret, as a byte of `after`: it says which of two same rows changed. + let caret = egui::TextEdit::load_state(ctx, id) + .and_then(|s| s.cursor.char_range()) + .map(|r| after.char_indices().nth(r.primary.index.into()).map_or(after.len(), |(at, _)| at)); let next = ctx.data_mut(|d| { let seen = d.get_temp_mut_or_default::(id.with(ALIGN_HISTORY)); if seen.iter().any(|(text, was)| text == before && *was != now) { @@ -163,7 +167,7 @@ pub(crate) fn follow_typing( remember(seen, before, now.clone()); let next = match seen.iter().rev().find(|(text, _)| text == after) { Some((_, was)) if was.len() == now.len() => was.clone(), - _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap(before, after, a)).collect(), + _ => now.iter().map(|a| pdfcraft_engine::paragraph_align::remap_at(before, after, a, caret)).collect(), }; remember(seen, after, next.clone()); next @@ -624,9 +628,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { let view_id = view.id; - // Clicks outside the document (the Format text panel) keep the paragraph open. Where the - // click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts. - let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // Clicks outside the document (the Format text panel) keep the paragraph open, as does the + // keyboard moving there. Where the click landed is `interact_pos`: `latest_pos` is gone on the + // frame a tap lifts. + let away = |p: egui::Pos2| !view.viewport_rect().contains(p); + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || focus_pos(ctx).is_some_and(away); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; @@ -821,6 +827,13 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option { }) } +/// Where the keyboard is: the centre of the widget that has it. A text box that loses it to a +/// widget reached by Tab or a screen reader (the Format text panel's controls) judges where it +/// went by this, as it judges a click by where the click landed. +pub(crate) fn focus_pos(ctx: &egui::Context) -> Option { + ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).map(|r| r.rect.center()) +} + /// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at /// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second /// frame as well, and an editor that judges a click away by where the click landed must judge it diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 1dadc6adaa..49ce6e4d0d 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -1014,6 +1014,77 @@ fn aligning_a_selected_line_of_added_text_aligns_only_that_line() { assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![])); } +/// Two lines of added text with the same words, each aligned its own way: deleting the first line +/// with its break leaves the second line's alignment, not the deleted one's (CodeRabbit on +/// 648dd428). +#[test] +fn deleting_the_first_of_two_same_lines_keeps_the_second_lines_alignment() { + use pdfcraft_engine::TextAlign::{Center, Right}; + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 220.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Same\nSame".into(), aligns: vec![Some(Center), Some(Right)], ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 120.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + // Select the first line with its break, and delete it. + h.key_press(egui::Key::ArrowUp); + h.key_press(egui::Key::Home); + // "Same" and its line break. + for _ in 0..5 { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::ArrowRight); + } + h.key_press(egui::Key::Backspace); + h.run_steps(2); + let d = h.state().views[0].content.draft.as_ref().expect("still typing"); + assert_eq!((d.text.as_str(), d.style.aligns.clone()), ("Same", vec![Some(Right)]), "the second line's alignment stays"); +} + +/// The same with the alignment controls reached by keyboard or a screen reader (focused, then +/// pressed, with no pointer): the box stays open when the focus leaves it for the Format text +/// panel, and both choices are for the selected line. +#[test] +fn aligning_a_selected_line_of_added_text_by_keyboard_keeps_the_box_open() { + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 220.0]; + let text = pdfcraft_engine::AddedText { + rect, + text: "Line one +Line two" + .into(), + ..Default::default() + }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 45.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::End); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Home); + h.run_steps(2); + let align_by_focus = |h: &mut Harness<'static, PdfCraftApp>, choice: &str| { + for label in ["Alignment", choice] { + h.get_by_label(label).focus(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + } + }; + let aligns = |h: &Harness<'static, PdfCraftApp>| match &added(h)[0].content { + pdfcraft_engine::AddedContent::Text(t) => (t.align, t.aligns.clone()), + _ => panic!(), + }; + align_by_focus(&mut h, "Align right"); + assert!(h.state().views[0].content.draft.is_some(), "the box stays open"); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Right)])); + align_by_focus(&mut h, "Centre"); + assert!(h.state().views[0].content.draft.is_some(), "the box is still open"); + assert_eq!(aligns(&h), (pdfcraft_engine::TextAlign::Left, vec![None, Some(pdfcraft_engine::TextAlign::Center)])); +} + /// The same in an added text box: Redo after Undo puts the typing back in the box, and the /// document's own Redo stays waiting. #[test] diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e69eb63d85..2087c293a6 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6578,6 +6578,39 @@ fn aligning_a_selected_row_aligns_only_that_row_and_undo_puts_it_back() { assert!((row_rect(&h, "nine")[0] - last[0]).abs() < 0.5, "the last row is back"); } +/// Format text's alignment flyout by keyboard or screen reader: each control is focused, then +/// pressed, with no pointer anywhere. +fn align_by_focus(h: &mut Harness<'static, PdfCraftApp>, choice: &str) { + for label in ["Alignment", choice] { + h.get_by_label(label).focus(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + } +} + +/// The same as by pointer, with the alignment controls reached by keyboard or a screen reader: +/// the paragraph stays open when the focus leaves it for the Format text panel, the selected row +/// takes the alignment, and a second choice is for that row again. +#[test] +fn aligning_a_selected_row_by_keyboard_keeps_the_paragraph_open() { + let mut h = three_row_paragraph(); + let first = row_rect(&h, "One"); + let wide = row_rect(&h, "three"); + h.key_press_modifiers(Modifiers::COMMAND, Key::End); + h.key_press_modifiers(Modifiers::SHIFT, Key::Home); + h.run_steps(2); + align_by_focus(&mut h, "Align right"); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph stays open"); + let moved = row_rect(&h, "nine"); + assert!((moved[2] - wide[2]).abs() < 1.0, "the last row is right-aligned: {moved:?}"); + align_by_focus(&mut h, "Centre"); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is still open"); + assert!((row_rect(&h, "One")[0] - first[0]).abs() < 0.5, "the first row stays put: {:?}", row_rect(&h, "One")); + let last = row_rect(&h, "nine"); + assert!((((last[0] + last[2]) - (wide[0] + wide[2])) / 2.0).abs() < 1.0, "the last row is centred: {last:?} in {wide:?}"); +} + /// A row aligned on its own, deleted by typing, then brought back with Undo from the menu: it /// comes back with its alignment, and Redo deletes it again. The box's history keeps only text, /// so the restored row took the alignment of the row before it. From 5c4cadce599e8a605da1f6a4f9e2bd85ecd0ba71 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 23 Oct 2026 21:48:03 +0200 Subject: [PATCH 45/82] M11: lossless JBIG2 for black-and-white images pdfcraft_filters::encode_jbig2_generic writes 1-bit rows as a JBIG2 stream for JBIG2Decode (embedded organisation: a page information segment and an immediate lossless generic region, no globals): template 0 with its nominal AT pixels, typical prediction, and the MQ arithmetic coder (mq.rs), written from the JBIG committee's ISO/IEC 14492 Final Committee Draft. The coder reproduces its Annex H.2 test sequence byte for byte; output round-trips through hayro-jbig2, and Poppler renders it the same as the original. The optimizer's Monochrome Images gets JBIG2 (lossless) beside CCITT Group 4 (still the default), doc_optimize mono jbig2; a document below PDF 1.4 gets /Version /1.4 in its catalog. On 300 dpi text JBIG2 is about a third smaller than Group 4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- Cargo.lock | 2 + Cargo.toml | 3 +- ROADMAP.md | 1 + crates/automation/src/documents.rs | 6 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 26 +- crates/filters/Cargo.toml | 1 + crates/filters/README.md | 8 +- crates/filters/src/jbig2.rs | 313 ++++++++++++++++++++ crates/filters/src/lib.rs | 3 + crates/filters/src/mq.rs | 209 +++++++++++++ crates/optimize/README.md | 12 +- crates/optimize/src/images.rs | 55 +++- crates/optimize/src/lib.rs | 7 +- crates/optimize/tests/optimize.rs | 61 ++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/ckb.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/kk.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/lo.tsv | 1 + crates/ui-egui/src/i18n/lt.tsv | 1 + crates/ui-egui/src/i18n/nb.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ro.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/sq.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/th.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/workflows/optimize_ui.rs | 2 + docs/roadmap.md | 2 + parity/acrobat-features.toml | 4 +- 51 files changed, 720 insertions(+), 30 deletions(-) create mode 100644 crates/filters/src/jbig2.rs create mode 100644 crates/filters/src/mq.rs diff --git a/Cargo.lock b/Cargo.lock index 42426bc688..4b09072f5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2731,6 +2731,7 @@ version = "0.3.0" dependencies = [ "fearless_simd", "hayro-ccitt", + "image", ] [[package]] @@ -4427,6 +4428,7 @@ dependencies = [ "fax", "flate2", "hayro-ccitt", + "hayro-jbig2", "proptest", "thiserror 2.0.21", "weezl 0.2.1", diff --git a/Cargo.toml b/Cargo.toml index 5a92b67475..b0b606c146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,8 +67,9 @@ flate2 = { version = "1.1", default-features = false, features = ["rust_backend" weezl = "0.2" # CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. fax = "0.2.7" -# An independent CCITT decoder, for round-trip tests; already in the tree through hayro. +# Independent CCITT and JBIG2 decoders, for round-trip tests; already in the tree through hayro. hayro-ccitt = "0.3" +hayro-jbig2 = "0.3" # foundation thiserror = "2" diff --git a/ROADMAP.md b/ROADMAP.md index 87f5b6705f..20c9f06d83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,6 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text. JBIG2 symbol coding, where its largest gains on text are, is next. - **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 40aa4ac522..880179b1eb 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -318,8 +318,9 @@ impl Automation { settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; if let Some(mono) = a.get("mono") { - let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, retain)")); - let mono = mono.as_object().ok_or_else(|| ToolError::InvalidArgs("mono must be an object: { compression: ccitt_g4|retain }".into()))?; + let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)")); + let mono = + mono.as_object().ok_or_else(|| ToolError::InvalidArgs("mono must be an object: { compression: ccitt_g4|jbig2|retain }".into()))?; // A misspelled key would otherwise leave the preset's setting in place unnoticed. if let Some(key) = mono.keys().find(|k| *k != "compression") { return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} (compression)"))); @@ -327,6 +328,7 @@ impl Automation { if let Some(c) = mono.get("compression") { settings.mono = match c.as_str() { Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4, + Some("jbig2") => MonoCompression::Jbig2, Some("retain") => MonoCompression::Retain, _ => return Err(unknown(c)), }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 2760be9298..3233935a59 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1433,7 +1433,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { compression: ccitt_g4|retain } (default ccitt_g4: black-and-white images and image masks are stored losslessly as CCITT Group 4 when that is smaller; counted in images_recompressed). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { compression: ccitt_g4|jbig2|retain } (default ccitt_g4: black-and-white images and image masks are stored losslessly as CCITT Group 4, or with jbig2 as lossless JBIG2, usually smaller still, when that is smaller than the image as it is; counted in images_recompressed). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index dcd321eddb..c0efb92efd 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6431,6 +6431,30 @@ fn scanned_page() -> Vec { write_full(&doc, &SaveOptions::default()).unwrap() } +/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2, +/// smaller than as Group 4. +#[test] +fn doc_optimize_stores_black_and_white_images_as_jbig2() { + let dir = workdir("optimize-jbig2"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" })); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}"); + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap(); + let filters: Vec> = written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"JBIG2Decode".to_vec()]); +} + /// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with /// `mono: { compression: retain }`. #[test] @@ -6457,7 +6481,7 @@ fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]); // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an // object is refused, not read as the default. - for mono in [json!({ "compression": "jbig2" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] { + for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); } } diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 2ed64eb8bd..876a8c2815 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -17,5 +17,6 @@ thiserror.workspace = true [dev-dependencies] hayro-ccitt.workspace = true +hayro-jbig2.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 413dff37c1..aeb082ef0f 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,7 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder. +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder. JBIG2 generic region encoding is written here from the JBIG committee's ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999), the public draft of ITU-T T.88; its Annex H.2 test sequence checks the arithmetic coder byte for byte. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -23,6 +23,7 @@ decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*part encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -42,6 +43,7 @@ encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows a | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not here (`Unsupported`; renderers decode it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | ## Tests @@ -49,8 +51,10 @@ encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows a - LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings. - `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams). - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. +- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode, DCT encode), CCITT Group 3 encoding and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2 and CCITT decode, DCT encode), CCITT Group 3 encoding, JBIG2 symbol coding (text regions, symbol dictionaries, lossy) and Flate encode levels other than 6. diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs new file mode 100644 index 0000000000..8983c335fd --- /dev/null +++ b/crates/filters/src/jbig2.rs @@ -0,0 +1,313 @@ +//! JBIG2 generic region encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` +//! (ISO 32000-2 §7.4.7): lossless, template 0 with its nominal adaptive pixels, typical prediction +//! on, MQ arithmetic coding ([`crate::mq`]). Written from the standard's 6.2.5 (coding a generic +//! region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 (the segments) and D.3 (the embedded +//! organisation PDF uses). + +use crate::FilterError; +use crate::mq::{Context, MqEncoder}; + +/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a +/// row's buffers small and its positions in range. +pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20; + +/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the +/// context in this order: the four on the current row, the five fixed ones on the row above, the +/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5). +const TEMPLATE: [(i32, i32); 16] = [ + (-1, 0), + (-2, 0), + (-3, 0), + (-4, 0), + (-2, -1), + (-1, -1), + (0, -1), + (1, -1), + (2, -1), + (-1, -2), + (0, -2), + (1, -2), + (3, -1), + (-3, -1), + (2, -2), + (-2, -2), +]; + +/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in +/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row, +/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that. +const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1]; + +/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it. +const PAD: usize = 4; + +/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on +/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an +/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits +/// without `/JBIG2Globals`. +/// +/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's +/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole +/// byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`] +/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let width = columns as usize; + let stride = width.div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "JBIG2Decode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let region = generic_region(samples, width, stride); + let mut out = Vec::with_capacity(region.len() + 64); + // Page information (7.4.8): the region's size, resolution unknown, eventually lossless, + // default pixel 0 (white), OR combination, not striped. + let mut page = Vec::with_capacity(19); + page.extend_from_slice(&columns.to_be_bytes()); + page.extend_from_slice(&rows.to_be_bytes()); + page.extend_from_slice(&[0; 8]); + page.push(0x01); + page.extend_from_slice(&[0, 0]); + segment(&mut out, 0, 48, &page); + // Immediate lossless generic region (7.4.6): region information (7.4.1) at (0, 0) with OR, + // flags (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then the data. + let mut generic = Vec::with_capacity(26 + region.len()); + generic.extend_from_slice(&columns.to_be_bytes()); + generic.extend_from_slice(&rows.to_be_bytes()); + generic.extend_from_slice(&[0; 8]); + generic.push(0); + generic.push(0x08); + for (x, y) in &TEMPLATE[12..] { + generic.push(*x as i8 as u8); + generic.push(*y as i8 as u8); + } + generic.extend_from_slice(®ion); + segment(&mut out, 1, 39, &generic); + Ok(out) +} + +/// A segment (7.2): its header (number, type, no referred-to segments, page 1, data length), then +/// its data. +fn segment(out: &mut Vec, number: u32, kind: u8, data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + out.push(0); + out.push(1); + // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. + out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(data); +} + +/// The arithmetic-coded data of a generic region (6.2.5.7) with template 0, the nominal AT pixels +/// and typical prediction. +fn generic_region(samples: &[u8], width: usize, stride: usize) -> Vec { + let mut contexts: Vec = vec![0; 1 << 16]; + let mut mq = MqEncoder::new(); + let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k); + // The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded. + let blank = vec![0u8; width + 2 * PAD]; + let (mut above2, mut above1) = (blank.clone(), blank.clone()); + let mut row = blank; + let mut ltp = false; + for packed in samples.chunks_exact(stride) { + for (x, pixel) in row.iter_mut().skip(PAD).take(width).enumerate() { + let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + *pixel = u8::from(!sample); + } + // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. + let typical = row == above1; + if let Some(cx) = contexts.get_mut(sltp) { + mq.encode(cx, typical != ltp); + } + ltp = typical; + if !typical { + for x in 0..width { + let at = |dx: i32, dy: i32| { + let line = match dy { + 0 => &row, + -1 => &above1, + _ => &above2, + }; + let i = (PAD + x).checked_add_signed(dx as isize); + i.and_then(|i| line.get(i)).copied().unwrap_or(0) + }; + let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k); + if let Some(state) = contexts.get_mut(cx) { + mq.encode(state, row.get(PAD + x) == Some(&1)); + } + } + } + std::mem::swap(&mut above2, &mut above1); + std::mem::swap(&mut above1, &mut row); + } + mq.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true + /// for white (a JBIG2 0). + fn decode(data: &[u8]) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(!black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); + image.decode(&mut rows).unwrap(); + rows.0 + } + + /// The rows of packed samples `data`, true for a 1 bit. + fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + /// Rows of `columns` samples from `runs`: alternating white and black runs one longer than + /// those values (never empty, even when shrinking makes them all 0), until the rows are full; + /// or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let jbig2 = encode_jbig2_generic(&data, w, h).unwrap(); + assert_eq!(decode(&jbig2), unpack(&data, w, h)); + let g4 = crate::encode_g4(&data, w, h).unwrap(); + assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len()); + } + + #[test] + fn edges_padding_and_typical_rows_round_trip() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]); + for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] { + let data = vec![fill; (w as usize).div_ceil(8) * h as usize]; + assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}"); + } + } + + /// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1. + fn samples(pixels: &[Vec]) -> Vec { + let width = pixels.first().map_or(0, Vec::len); + let stride = width.div_ceil(8); + let mut data = vec![0xFF; stride * pixels.len()]; + for (y, row) in pixels.iter().enumerate() { + for (x, p) in row.iter().enumerate() { + if *p == 1 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + data + } + + /// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels. + #[test] + fn a_repeated_row_is_one_decision() { + let row: Vec = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect(); + let pixels = vec![row; 1000]; + let data = samples(&pixels); + crate::mq::DECISIONS.with(|n| n.set(0)); + let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap(); + // The first row's 800 pixels, and one typical-row decision for each of the 1000 rows. + assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000); + assert_eq!(decode(&jbig2), unpack(&data, 800, 1000)); + } + + /// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image + /// tiled with that very neighbourhood decodes only if both coders use the same context. + #[test] + fn the_typical_row_flag_shares_figure_8s_context() { + // Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up + // x = 1..7, the pixel's own row x = 0..3. + let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]]; + let pixels: Vec> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect(); + let data = samples(&pixels); + assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90)); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder. + #[test] + fn bitmaps_round_trip( + columns in 1u32..300, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2_generic(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index b9461435eb..da675a024b 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -18,13 +18,16 @@ mod ascii85; mod asciihex; mod ccitt; mod flate; +mod jbig2; mod lzw; +mod mq; mod predictor; mod runlength; use std::borrow::Cow; pub use ccitt::{MAX_G4_COLUMNS, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/filters/src/mq.rs b/crates/filters/src/mq.rs new file mode 100644 index 0000000000..04dda2bd9a --- /dev/null +++ b/crates/filters/src/mq.rs @@ -0,0 +1,209 @@ +//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the +//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1. + +/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and +/// after an LPS renormalisation, and whether an LPS switches the MPS sense. +const STATES: [(u32, u8, u8, bool); 47] = [ + (0x5601, 1, 1, true), + (0x3401, 2, 6, false), + (0x1801, 3, 9, false), + (0x0AC1, 4, 12, false), + (0x0521, 5, 29, false), + (0x0221, 38, 33, false), + (0x5601, 7, 6, true), + (0x5401, 8, 14, false), + (0x4801, 9, 14, false), + (0x3801, 10, 14, false), + (0x3001, 11, 17, false), + (0x2401, 12, 18, false), + (0x1C01, 13, 20, false), + (0x1601, 29, 21, false), + (0x5601, 15, 14, true), + (0x5401, 16, 14, false), + (0x5101, 17, 15, false), + (0x4801, 18, 16, false), + (0x3801, 19, 17, false), + (0x3401, 20, 18, false), + (0x3001, 21, 19, false), + (0x2801, 22, 19, false), + (0x2401, 23, 20, false), + (0x2201, 24, 21, false), + (0x1C01, 25, 22, false), + (0x1801, 26, 23, false), + (0x1601, 27, 24, false), + (0x1401, 28, 25, false), + (0x1201, 29, 26, false), + (0x1101, 30, 27, false), + (0x0AC1, 31, 28, false), + (0x09C1, 32, 29, false), + (0x08A1, 33, 30, false), + (0x0521, 34, 31, false), + (0x0441, 35, 32, false), + (0x02A1, 36, 33, false), + (0x0221, 37, 34, false), + (0x0141, 38, 35, false), + (0x0111, 39, 36, false), + (0x0085, 40, 37, false), + (0x0049, 41, 38, false), + (0x0025, 42, 39, false), + (0x0015, 43, 40, false), + (0x0009, 44, 41, false), + (0x0005, 45, 42, false), + (0x0001, 45, 43, false), + (0x5601, 46, 46, false), +]; + +#[cfg(test)] +thread_local! { + /// Decisions coded on this thread. + pub(crate) static DECISIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7). +/// 0, the initial value, is index 0 with MPS 0 (E.2.8). +pub(crate) type Context = u8; + +/// The encoder's registers (E.2.1) and the bytes written so far. +pub(crate) struct MqEncoder { + a: u32, + c: u32, + ct: u32, + /// The output, after the byte before the first one (B at INITENC), which is 0x00 and is + /// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out. + out: Vec, +} + +impl MqEncoder { + /// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12. + pub(crate) fn new() -> Self { + MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] } + } + + /// The byte B, the last one written (or the one before the first). + fn b(&self) -> u8 { + self.out.last().copied().unwrap_or(0) + } + + /// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state. + pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) { + #[cfg(test)] + DECISIONS.with(|n| n.set(n.get() + 1)); + let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1); + let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return }; + self.a -= qe; + if d == mps { + // CODEMPS. + if self.a & 0x8000 != 0 { + self.c += qe; + return; + } + if self.a < qe { + self.a = qe; + } else { + self.c += qe; + } + *cx = nmps | (*cx & 0x80); + } else { + // CODELPS. + if self.a < qe { + self.c += qe; + } else { + self.a = qe; + } + let mps = mps != switch; + *cx = nlps | (u8::from(mps) << 7); + } + self.renorm(); + } + + /// RENORME (Figure E.8). + fn renorm(&mut self) { + loop { + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + if self.ct == 0 { + self.byte_out(); + } + if self.a & 0x8000 != 0 { + break; + } + } + } + + /// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before. + fn byte_out(&mut self) { + if self.b() == 0xFF { + self.stuffed(); + } else if self.c < 0x800_0000 { + self.plain(); + } else { + let b = self.b().wrapping_add(1); + if let Some(last) = self.out.last_mut() { + *last = b; + } + if b == 0xFF { + self.c &= 0x7FF_FFFF; + self.stuffed(); + } else { + self.plain(); + } + } + } + + fn plain(&mut self) { + self.out.push(((self.c >> 19) & 0xFF) as u8); + self.c &= 0x7_FFFF; + self.ct = 8; + } + + fn stuffed(&mut self) { + self.out.push(((self.c >> 20) & 0xFF) as u8); + self.c &= 0xF_FFFF; + self.ct = 7; + } + + /// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker. + pub(crate) fn finish(mut self) -> Vec { + let top = self.c + self.a; + self.c |= 0xFFFF; + if self.c >= top { + self.c -= 0x8000; + } + self.c <<= self.ct; + self.byte_out(); + self.c <<= self.ct; + self.byte_out(); + if self.b() != 0xFF { + self.out.push(0xFF); + } + self.out.push(0xAC); + self.out.split_off(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes. + #[test] + fn the_annex_h_test_sequence_encodes_to_its_bytes() { + let decisions = [ + 0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E, + 0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF, + ]; + let mut mq = MqEncoder::new(); + let mut cx = 0; + for byte in decisions { + for bit in (0..8).rev() { + mq.encode(&mut cx, byte >> bit & 1 == 1); + } + } + let expected = [ + 0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37, + 0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC, + ]; + assert_eq!(mq.finish(), expected); + } +} diff --git a/crates/optimize/README.md b/crates/optimize/README.md index c3281f00c4..5162cdbbaf 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -34,13 +34,15 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by reference too, the numbers inside included (`Document::with_direct_parms`), here and below: without them a predictor's tag bytes would shift every row of an image written back. -- **Monochrome images** (`Settings::mono`, CCITT Group 4 by default): a 1-bit image with one - colour component (grey, indexed, a separation) or an image mask, stored with no filter or with - general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`) when - that is smaller as stored. It is lossless: the samples decode to the same bits, so the colour +- **Monochrome images** (`Settings::mono`, CCITT Group 4 by default, or JBIG2): a 1-bit image + with one colour component (grey, indexed, a separation) or an image mask, stored with no filter + or with general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`) + or lossless JBIG2 (`encode_jbig2_generic`; a document below PDF 1.4 gets `/Version /1.4` in + its catalog) when that is smaller as stored. On 300 dpi text JBIG2 is about a third smaller + than Group 4. It is lossless: the samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: - JBIG2, monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider + JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet: a mark over part of an image stored this way removes the whole image (nothing leaks). Nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 57e583a078..8b2f30a8f0 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -562,15 +562,17 @@ fn one_component(doc: &Document, cs: &Object) -> bool { } } -/// A black-and-white image (one bit per pixel, or an image mask) stored again as CCITT Group 4, -/// if `compression` asks for that and the result is smaller as stored. Only images whose samples +/// A black-and-white image (one bit per pixel, or an image mask) stored again as `compression` +/// asks (CCITT Group 4 or JBIG2), if the result is smaller as stored. Only images whose samples /// PdfCraft decodes (no filter, or general-purpose filters: image codecs don't decode here) /// qualify. The samples decode to the same bits, so the colour space, decode array and masks stay /// as they are. fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option { - if compression != MonoCompression::CcittG4 { - return None; - } + let widest = match compression { + MonoCompression::CcittG4 => pdfcraft_filters::MAX_G4_COLUMNS, + MonoCompression::Jbig2 => pdfcraft_filters::MAX_JBIG2_COLUMNS, + MonoCompression::Retain => return None, + }; let d = &s.dict; let bits = d.int(b"BitsPerComponent"); let qualifies = if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { @@ -582,24 +584,31 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option pdfcraft_filters::MAX_G4_COLUMNS || u64::from(w) * u64::from(h) > MAX_PIXELS { + if w == 0 || h == 0 || w > widest || u64::from(w) * u64::from(h) > MAX_PIXELS { return None; } // Through its parameters given by reference too, as `pixels` reads, and no further than its // rows can need. let bytes = usize::try_from(w).ok()?.div_ceil(8).checked_mul(usize::try_from(h).ok()?)?; - let g4 = pdfcraft_filters::encode_g4(&doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?, w, h).ok()?; + let samples = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; let mut new = d.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { new.remove(k); } - let mut parms = Dict::new(); - parms.set(b"K".to_vec(), Object::Int(-1)); - parms.set(b"Columns".to_vec(), Object::Int(i64::from(w))); - parms.set(b"Rows".to_vec(), Object::Int(i64::from(h))); - new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); - new.set(b"DecodeParms".to_vec(), Object::Dict(parms)); - let new = Stream::from_raw(new, g4); + let data = if compression == MonoCompression::Jbig2 { + // One embedded JBIG2 stream with its own page; no /JBIG2Globals. + new.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + pdfcraft_filters::encode_jbig2_generic(&samples, w, h).ok()? + } else { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(w))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(h))); + new.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + new.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + pdfcraft_filters::encode_g4(&samples, w, h).ok()? + }; + let new = Stream::from_raw(new, data); // Smaller as stored, counting the larger dictionary: a tiny image can lose by it. (crate::stored_len(&new)? < crate::stored_len(s)?).then_some(new) } @@ -623,6 +632,7 @@ pub(crate) fn run( let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); + let mut jbig2 = false; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -634,6 +644,7 @@ pub(crate) fn run( report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); report.images_recompressed += 1; + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); doc.set(r, Object::Stream(new)); continue; } @@ -656,6 +667,22 @@ pub(crate) fn run( if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } + if jbig2 { + at_least_pdf_1_4(doc)?; + } + Ok(()) +} + +/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 +/// or more already, gets `/Version /1.4` in its catalog (ISO 32000-2 §7.7.2). A full save with +/// object streams writes a 1.5 header anyway. +fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { + let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..]; + let Some(root) = doc.root() else { return Ok(()) }; + let catalog = doc.get(root).as_dict().and_then(|c| c.name(b"Version").map(<[u8]>::to_vec)); + if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?; + } Ok(()) } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index f4f7baf988..09c487989e 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -10,8 +10,8 @@ //! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode //! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, //! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` -//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4, -//! losslessly, when that is smaller. +//! disagree) are left alone. Black-and-white (1-bit) images are stored again as CCITT Group 4 +//! or JBIG2, losslessly, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted @@ -124,6 +124,9 @@ pub enum Compression { pub enum MonoCompression { /// CCITT Group 4 (lossless), when smaller than the image as it is. CcittG4, + /// JBIG2, lossless (a generic region), when smaller than the image as it is. JBIG2Decode is + /// PDF 1.4: an older document gets `/Version /1.4` in its catalog. + Jbig2, /// Keep each image's own compression. Retain, } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3fce1dadba..aaebc5f4f5 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -588,6 +588,67 @@ fn images_whose_data_decodes_far_past_their_samples_are_left_alone() { assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled"); } +/// The images of a document that are images, by object number. +fn image_refs(doc: &Document) -> Vec { + doc.object_numbers() + .into_iter() + .map(|num| ObjRef { num, generation: doc.generation(num) }) + .filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .collect() +} + +/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so +/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller +/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its +/// catalog. +#[test] +fn black_and_white_images_become_jbig2() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q", + ); + // The same document, as PDF 1.3. + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.version(), "1.3"); + let before = render(&doc); + + let mut g4 = doc.clone(); + let as_g4 = optimize(&mut g4, &Settings::default()).unwrap(); + let mut jbig2 = doc.clone(); + let r = optimize(&mut jbig2, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 3); + assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after); + let images: Vec = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect(); + assert_eq!(images.len(), 3); + for s in &images { + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); + let indexed_kept = + |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed"); + assert!(images.iter().any(indexed_kept), "the colour space stays"); + let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap(); + assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..])); + assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0"); + assert!(render(&jbig2) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4f43912938..65432242f3 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -599,6 +599,7 @@ Grayscale Images الصور الرمادية Monochrome Images الصور أحادية اللون CCITT Group 4 CCITT المجموعة 4 + JBIG2 (lossless) JBIG2 (بدون فقدان) Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى for images above للصور التي تتجاوز Compression الضغط diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 9464683b36..7c3d6ca605 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -261,6 +261,7 @@ Grayscale Images Изображения в сива скала Monochrome Images Монохромни изображения CCITT Group 4 CCITT група 4 + JBIG2 (lossless) JBIG2 (без загуби) Bicubic downsampling to Бикубично понижаване на резолюцията до for images above за изображения над Compression Компресия diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index c7d351ac13..0b6b545f2d 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -562,6 +562,7 @@ Grayscale Images Imatges en escala de grisos Monochrome Images Imatges monocromes CCITT Group 4 CCITT grup 4 + JBIG2 (lossless) JBIG2 (sense pèrdua) Bicubic downsampling to Reducció bicúbica de resolució a for images above per a imatges de més de Compression Compressió diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index c5fa4b6967..0b9135240a 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -569,6 +569,7 @@ Grayscale Images وێنە خۆڵەمێشییەکان Monochrome Images وێنە یەکڕەنگەکان CCITT Group 4 CCITT گرووپی 4 + JBIG2 (lossless) JBIG2 (بێ لەدەستدان) Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ for images above بۆ ئەو وێنانەی سەرووی Compression پەستاندن diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 342f762378..2b50b6a948 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -683,6 +683,7 @@ key Space Mezerník Grayscale Images Obrázky ve stupních šedi Monochrome Images Monochromatické obrázky CCITT Group 4 CCITT skupina 4 + JBIG2 (lossless) JBIG2 (bezeztrátová) Bicubic downsampling to Bikubické převzorkování na for images above u obrázků nad Compression Komprese diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 71163b6b96..69d8030d3d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -572,6 +572,7 @@ Grayscale Images Graustufenbilder Monochrome Images Monochrome Bilder CCITT Group 4 CCITT Gruppe 4 + JBIG2 (lossless) JBIG2 (verlustfrei) Bicubic downsampling to Bikubische Neuberechnung auf for images above für Bilder über Compression Komprimierung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 13f762d6ca..3599744eb8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1908,6 +1908,7 @@ Grayscale Images Εικόνες σε κλίμακα του γκρι Monochrome Images Μονόχρωμες εικόνες CCITT Group 4 CCITT Ομάδα 4 + JBIG2 (lossless) JBIG2 (χωρίς απώλειες) Bicubic downsampling to Δικυβική μείωση ανάλυσης σε for images above για εικόνες πάνω από ZIP ZIP diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0457264377..1665279bce 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -595,6 +595,7 @@ Grayscale Images Imágenes en escala de grises Monochrome Images Imágenes monocromas CCITT Group 4 CCITT grupo 4 + JBIG2 (lossless) JBIG2 (sin pérdida) Bicubic downsampling to Reducción de resolución bicúbica a for images above para imágenes por encima de Compression Compresión diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 5bd26bff11..94e7fe0119 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -562,6 +562,7 @@ Grayscale Images Harmaasävykuvat Monochrome Images Mustavalkokuvat CCITT Group 4 CCITT ryhmä 4 + JBIG2 (lossless) JBIG2 (häviötön) Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen for images above kuville, joiden tarkkuus yli Compression Pakkaus diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b5434915af..7e6ca64aef 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -572,6 +572,7 @@ Grayscale Images Images en niveaux de gris Monochrome Images Images monochromes CCITT Group 4 CCITT groupe 4 + JBIG2 (lossless) JBIG2 (sans perte) Bicubic downsampling to Sous-échantillonnage bicubique à for images above pour les images au-dessus de Compression Compression diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 30aa915af7..fadf4fbade 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -546,6 +546,7 @@ Grayscale Images ग्रेस्केल छवियाँ Monochrome Images मोनोक्रोम छवियाँ CCITT Group 4 CCITT समूह 4 + JBIG2 (lossless) JBIG2 (हानिरहित) Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक for images above इससे बड़ी छवियों के लिए Compression संपीड़न diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b7e494ab0c..d16aa23dc9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -570,6 +570,7 @@ Grayscale Images Szürkeárnyalatos képek Monochrome Images Monokróm képek CCITT Group 4 CCITT 4. csoport + JBIG2 (lossless) JBIG2 (veszteségmentes) Bicubic downsampling to Kettős köbös újramintázás erre for images above az ennél nagyobb képeknél Compression Tömörítés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c87527fee3..1783118fff 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2057,6 +2057,7 @@ comment menu Edit Edit Grayscale Images Gambar Skala Abu-abu Monochrome Images Gambar Monokrom CCITT Group 4 CCITT Grup 4 + JBIG2 (lossless) JBIG2 (lossless) Green Hijau Green on black Hijau di atas hitam H / Space (hold) H / Space (tahan) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 62b012f091..95b83f1a3a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -596,6 +596,7 @@ Grayscale Images Immagini in scala di grigi Monochrome Images Immagini monocromatiche CCITT Group 4 CCITT gruppo 4 + JBIG2 (lossless) JBIG2 (senza perdita) Bicubic downsampling to Sottocampionamento bicubico a for images above per immagini oltre Compression Compressione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index beb3002eb6..92b78ef14c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -599,6 +599,7 @@ Grayscale Images グレースケール画像 Monochrome Images 白黒画像 CCITT Group 4 CCITT グループ 4 + JBIG2 (lossless) JBIG2(可逆) Bicubic downsampling to バイキュービック法でダウンサンプル: for images above 対象画像の解像度: Compression 圧縮 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 08a390dade..f5147c8b48 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -559,6 +559,7 @@ Grayscale Images Сұр реңкті кескіндер Monochrome Images Монохромды кескіндер CCITT Group 4 CCITT 4-топ + JBIG2 (lossless) JBIG2 (шығынсыз) Bicubic downsampling to Бикубтық төмендету for images above мұнан асатын кескіндер үшін Compression Сығымдау diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1b6dcc99ee..3807c0a387 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -546,6 +546,7 @@ Grayscale Images 회색조 이미지 Monochrome Images 흑백 이미지 CCITT Group 4 CCITT 그룹 4 + JBIG2 (lossless) JBIG2(무손실) Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링 for images above 다음 값을 초과하는 이미지에 적용 Compression 압축 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 168fccb36e..270464b875 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -567,6 +567,7 @@ Grayscale Images ຮູບພາບລະດັບສີເທົາ Monochrome Images ຮູບພາບຂາວດຳ CCITT Group 4 CCITT ກຸ່ມ 4 + JBIG2 (lossless) JBIG2 (ບໍ່ສູນເສຍ) Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ: for images above ສຳລັບຮູບພາບທີ່ເກີນ: Compression ການບີບອັດ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 33ffe03418..75ed169130 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2125,6 +2125,7 @@ comment menu Edit Redaguoti Grayscale Images Pilkų atspalvių paveikslėliai Monochrome Images Vienspalviai paveikslėliai CCITT Group 4 CCITT 4 grupė + JBIG2 (lossless) JBIG2 (be nuostolių) Green Žalia Green on black Žalia juodame fone H / Space (hold) H / tarpo klavišas (laikyti) diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index f3f37a1afa..bceb1d1e85 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2062,6 +2062,7 @@ Grayscale Images Gråtonebilder Monochrome Images Svart-hvitt-bilder CCITT Group 4 CCITT gruppe 4 + JBIG2 (lossless) JBIG2 (tapsfri) Green Grønn Green on black Grønn på svart H / Space (hold) H / Mellomrom (hold) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0dd3604189..480c584c19 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -594,6 +594,7 @@ Grayscale Images Obrazy w skali szarości Monochrome Images Obrazy monochromatyczne CCITT Group 4 CCITT grupa 4 + JBIG2 (lossless) JBIG2 (bezstratna) Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do for images above dla obrazów powyżej Compression Kompresja diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6ab4ec9d85..da198aa840 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -218,6 +218,7 @@ Grayscale Images Imagens em escala de cinza Monochrome Images Imagens monocromáticas CCITT Group 4 CCITT grupo 4 + JBIG2 (lossless) JBIG2 (sem perdas) Bicubic downsampling to Redução bicúbica de amostragem para for images above para imagens acima de Compression Compactação diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 0b0050a4f1..e371d5746b 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2068,6 +2068,7 @@ comment menu Edit Editare Grayscale Images Imagini în tonuri de gri Monochrome Images Imagini monocrome CCITT Group 4 CCITT grupa 4 + JBIG2 (lossless) JBIG2 (fără pierderi) Green Verde Green on black Verde pe negru H / Space (hold) H / Spațiu (ținut apăsat) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f9a17709ba..43314090b4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -592,6 +592,7 @@ Grayscale Images Изображения в оттенках серого Monochrome Images Монохромные изображения CCITT Group 4 CCITT группа 4 + JBIG2 (lossless) JBIG2 (без потерь) Bicubic downsampling to Бикубическое уменьшение до for images above для изображений выше Compression Сжатие diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 83790b055e..0cbb9fca3d 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -893,6 +893,7 @@ Grayscale Images Sivinske slike Monochrome Images Enobarvne slike CCITT Group 4 CCITT skupina 4 + JBIG2 (lossless) JBIG2 (brez izgub) Green Zelena H / Space (hold) H / preslednica (drži) HTML web page Spletna stran HTML diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index cc899a668a..ef0d3e5719 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -882,6 +882,7 @@ Grayscale Images Imazhe në nuanca gri Monochrome Images Imazhe bardhezi CCITT Group 4 CCITT grupi 4 + JBIG2 (lossless) JBIG2 (pa humbje) Green Jeshile H / Space (hold) H / Hapësira (mbaje shtypur) HTML web page Faqe interneti HTML diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 43a2782674..a42f6947b2 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -576,6 +576,7 @@ comment menu Edit సవరించు Grayscale Images గ్రేస్కేల్ చిత్రాలు Monochrome Images మోనోక్రోమ్ చిత్రాలు CCITT Group 4 CCITT గ్రూప్ 4 + JBIG2 (lossless) JBIG2 (నష్టరహితం) Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం: for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు: Compression కుదింపు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 4eeeb6f951..49b81aee50 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -556,6 +556,7 @@ Grayscale Images รูปภาพระดับสีเทา Monochrome Images รูปภาพขาวดำ CCITT Group 4 CCITT กลุ่ม 4 + JBIG2 (lossless) JBIG2 (ไม่สูญเสียข้อมูล) Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น for images above สำหรับรูปภาพที่สูงกว่า Compression การบีบอัด diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d5124513cf..42548981e8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -557,6 +557,7 @@ Grayscale Images Gri Tonlamalı Resimler Monochrome Images Tek Renkli Resimler CCITT Group 4 CCITT Grup 4 + JBIG2 (lossless) JBIG2 (kayıpsız) Bicubic downsampling to Şu değere bikübik alt örnekleme: for images above bundan büyük resimler için: Compression Sıkıştırma diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8051e42f82..83f25e0b36 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -597,6 +597,7 @@ Grayscale Images Зображення у відтінках сірого Monochrome Images Монохромні зображення CCITT Group 4 CCITT група 4 + JBIG2 (lossless) JBIG2 (без втрат) Bicubic downsampling to Бікубічне зниження роздільної здатності до for images above для зображень із роздільною здатністю понад Compression Стиснення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8703b4c8ee..9201a1feb7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2113,6 +2113,7 @@ comment menu Edit Sửa Grayscale Images Ảnh thang xám Monochrome Images Ảnh đơn sắc CCITT Group 4 CCITT nhóm 4 + JBIG2 (lossless) JBIG2 (không mất dữ liệu) Green Xanh lá Green on black Xanh lá trên nền đen H / Space (hold) H / Space (giữ) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index da2722c7fa..0d670e5984 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -267,6 +267,7 @@ Grayscale Images 灰度图像 Monochrome Images 单色图像 CCITT Group 4 CCITT 第 4 组 + JBIG2 (lossless) JBIG2(无损) Bicubic downsampling to 双三次降采样至 for images above (高于此分辨率的图像) Compression 压缩 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8f42d9da3b..11db509b86 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -223,6 +223,7 @@ Grayscale Images 灰階圖片 Monochrome Images 單色圖片 CCITT Group 4 CCITT 第 4 組 + JBIG2 (lossless) JBIG2(無損) Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index d3a5cfd4a1..32052eb1f7 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -150,10 +150,12 @@ fn mono_row(ui: &mut egui::Ui, m: &mut MonoCompression) { ui.label(tl!("Compression")); let label = match m { MonoCompression::CcittG4 => tl!("CCITT Group 4"), + MonoCompression::Jbig2 => tl!("JBIG2 (lossless)"), MonoCompression::Retain => tl!("Retain existing"), }; craft_ui::ComboBox::from_id_salt(("mono", "compression")).selected_text(label).show_ui(ui, |ui| { ui.selectable_value(m, MonoCompression::CcittG4, tl!("CCITT Group 4")); + ui.selectable_value(m, MonoCompression::Jbig2, tl!("JBIG2 (lossless)")); ui.selectable_value(m, MonoCompression::Retain, tl!("Retain existing")); }); }); diff --git a/docs/roadmap.md b/docs/roadmap.md index 75be8e3833..872c524870 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -256,6 +257,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Symbol coding not yet. Estimates unchanged. | | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 02d7219aba..be8c1b904e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7384,8 +7384,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: JBIG2 (lossless or lossy, which Adobe's help says beats CCITT for monochrome), monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From a0690037825edac730e2dba0f54b06b174eaae34 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 24 Oct 2026 01:34:48 +0200 Subject: [PATCH 46/82] =?UTF-8?q?M11:=20Export=20=E2=96=B8=20Images=20deco?= =?UTF-8?q?des=20JBIG2=20images?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JBIG2 images, and JBIG2 stencil masks, were reported as "can't be exported yet", so an image the Optimizer stores as JBIG2 would stop exporting. They are now decoded with hayro-jbig2 (already in the tree through hayro), with their /JBIG2Globals from a DecodeParms dictionary or array, as JBIG2Decode gives them: black as 0 (ISO 32000-2 §7.4.7). Unreadable globals, a JBIG2 page of another width, or fewer rows than the image skip the image; rows past its height are dropped, so the output is bounded by the checked size. docs/pdf-spec-parity.md §7.4 no longer says there is no JBIG2 encoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- Cargo.lock | 2 + Cargo.toml | 3 +- ROADMAP.md | 2 +- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 11 ++- crates/create/Cargo.toml | 3 + crates/create/README.md | 2 + crates/create/src/extract.rs | 107 +++++++++++++++++++++-- crates/create/src/tests.rs | 79 +++++++++++++++++ crates/filters/README.md | 2 +- docs/pdf-spec-parity.md | 5 +- docs/roadmap.md | 4 +- parity/acrobat-features.toml | 4 +- 13 files changed, 209 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b09072f5b..efe448b11a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4334,9 +4334,11 @@ name = "pdfcraft-create" version = "0.15.0" dependencies = [ "encoding_rs", + "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", "pdfcraft-xfa", diff --git a/Cargo.toml b/Cargo.toml index b0b606c146..6dafba9eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,8 @@ flate2 = { version = "1.1", default-features = false, features = ["rust_backend" weezl = "0.2" # CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. fax = "0.2.7" -# Independent CCITT and JBIG2 decoders, for round-trip tests; already in the tree through hayro. +# CCITT and JBIG2 decoders, already in the tree through hayro: independent decoders for round-trip +# tests, and JBIG2 for Export ▸ Images. hayro-ccitt = "0.3" hayro-jbig2 = "0.3" diff --git a/ROADMAP.md b/ROADMAP.md index 20c9f06d83..f2fa3e25a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,7 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. -- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text. JBIG2 symbol coding, where its largest gains on text are, is next. +- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. - **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3233935a59..ffc17cb457 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1274,7 +1274,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, CCITT, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c0efb92efd..835db4c755 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6432,7 +6432,7 @@ fn scanned_page() -> Vec { } /// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2, -/// smaller than as Group 4. +/// smaller than as Group 4, and exports as the same picture. #[test] fn doc_optimize_stores_black_and_white_images_as_jbig2() { let dir = workdir("optimize-jbig2"); @@ -6453,6 +6453,15 @@ fn doc_optimize_stores_black_and_white_images_as_jbig2() { }) .collect(); assert_eq!(filters, [b"JBIG2Decode".to_vec()]); + // Export ▸ Images gives the copy's picture back as it gives the scan's. + let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap(); + let mut exported = |doc: u64, folder: &str| { + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + std::fs::read(files[0]["path"].as_str().unwrap()).unwrap() + }; + assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan")); } /// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index 12daad621c..a7f3df0de6 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } # For `image::bmp_with_pixel_offset`, shared with XFA pictures. pdfcraft-xfa = { workspace = true } +# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. +hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } @@ -26,3 +28,4 @@ encoding_rs = "0.8" [dev-dependencies] image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } hayro-syntax = { workspace = true } +pdfcraft-filters = { workspace = true } diff --git a/crates/create/README.md b/crates/create/README.md index d1561a6333..f49e53dad4 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -25,6 +25,8 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, +as `JBIG2Decode` gives them: black as 0. A JBIG2 page of another width than the image is skipped. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 7828952edc..36825e6ad6 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,7 +1,7 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with //! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour -//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are +//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, CCITT, separations) are //! reported, never silently left out. use std::collections::HashSet; @@ -100,6 +100,101 @@ fn filters(s: &Stream) -> Vec> { } } +/// The samples of image `s`, decoded: through its filters, or, for a JBIG2 image (its only +/// filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples each +/// starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +fn decoded(doc: &Document, s: &Stream) -> Result, String> { + match filters(s).as_slice() { + [f] if f == b"JBIG2Decode" => jbig2(doc, s), + _ => s.decoded().map_err(|e| e.to_string()), + } +} + +/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). +fn decode_parms(doc: &Document, s: &Stream) -> Dict { + let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; + let p = match &*p { + Object::Array(a) => match a.first() { + Some(first) => doc.resolve(first), + None => return Dict::default(), + }, + _ => p, + }; + p.as_dict().cloned().unwrap_or_default() +} + +/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller +/// checked against the export's size limit; rows past the image's height are dropped, and too few +/// rows are refused by the caller. Globals that can't be read refuse the image, rather than +/// decoding it without them. +fn jbig2(doc: &Document, s: &Stream) -> Result, String> { + let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { + Some(Object::Stream(g)) => Some(g.decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?), + _ => None, + }; + let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; + if s.dict.int(b"Width") != Some(i64::from(image.width())) { + return Err("the JBIG2 image's width isn't the image's width".into()); + } + let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); + let mut rows = Rows::new(image.width() as usize, height); + image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; + Ok(rows.out) +} + +/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` +/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so +/// the output is bounded by the size the caller gave. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, +} + +impl Rows { + fn new(columns: usize, rows: usize) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } + } + + /// `count` pixels, as 1 samples when `one`. + fn push(&mut self, one: bool, count: usize) { + let end = self.x.saturating_add(count); + if one { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } + + fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. @@ -108,7 +203,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()), + Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} @@ -148,7 +243,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> space = decoded; (samples, 8) } else { - let data = s.decoded().map_err(|e| e.to_string())?; + let data = decoded(doc, s)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -466,7 +561,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); @@ -476,7 +571,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? } - Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None, + Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"CCITTFaxDecode" | b"CCF") => return None, _ => { let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { @@ -488,7 +583,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 5cd34a0f1a..8a861270db 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1338,3 +1338,82 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() { let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap()); assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); } + +/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as +/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in +/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the +/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, +/// or data that isn't JBIG2, skips the image. +#[test] +fn jbig2_images_export_as_their_picture() { + let (w, h) = (203i64, 41i64); + let stride = 26; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let bits: Vec = (0..stride * h as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + // The first `rows` rows as RGBA: a 1 sample white, a 0 black. + let picture = |rows: i64| -> Vec { + (0..rows as usize) + .flat_map(|y| (0..w as usize).map(move |x| (x, y))) + .flat_map(|(x, y)| { + let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 }; + [v, v, v, 255] + }) + .collect() + }; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + // A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms + // (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey + // image. + let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(width, height, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + if let Some((g, in_array)) = globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) }); + } + if !mask { + return Stream::from_raw(d, data.to_vec()); + } + d.remove(b"ColorSpace"); + d.set(b"ImageMask".to_vec(), Object::Bool(true)); + let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))); + let mut image = image_dict(width, height, "DeviceGray", 8); + image.set(b"Mask".to_vec(), Object::Ref(m)); + Stream::from_raw(image, vec![128; (width * height) as usize]) + }); + doc + }; + let skipped = |doc: &Document| { + let out = extract_images(doc, &[0], 0); + out.images.is_empty() && out.skipped.len() == 1 + }; + + assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h))); + assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals"); + for in_array in [false, true] { + assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); + } + assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); + for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); + } + // Damaged data never panics. + for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { + let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); + } + + // As a stencil /Mask, its 1 samples (JBIG2 white) hide the image. + let alpha: Vec = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect(); + assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); +} diff --git a/crates/filters/README.md b/crates/filters/README.md index aeb082ef0f..98363e33dc 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -43,7 +43,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1 | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; renderers decode it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | ## Tests diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index dba04f6c4c..a1c26eefb4 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: CCITT Group 4 encoding in the Optimizer; earlier, §14.3: custom properties edited in Document Properties) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: lossless JBIG2 encoding in the Optimizer; JBIG2 images export; earlier, §7.4: CCITT Group 4 encoding in the Optimizer) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -21,7 +21,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 |---|---|---|---|---| | §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | | §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | -| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; the Optimizer encodes CCITT Group 4 (`pdfcraft_filters::encode_g4`, `optimize.mono-compression`); no JBIG2 encoding | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 encoding; **own** JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; the Optimizer encodes CCITT Group 4 (`pdfcraft_filters::encode_g4`) and lossless JBIG2 (a generic region; no symbol coding), `optimize.mono-compression` | | §7.5.8 | Linearization | R W | R only | `core.linearization` planned | | §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; written files open in Acrobat DC since #836 (#774: Acrobat needs the catalog outside object streams) | | §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | @@ -130,6 +130,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | §7.4: lossless JBIG2 generic-region encoding (own, from the JBIG2 draft standard) in the Optimizer; Export ▸ Images decodes JBIG2 | | 2026-10-26 | minor | §7.4: CCITT Group 4 encoding (`fax`) in the Optimizer | | 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | | 2026-10-11 | minor | §7.6.4: encrypted files we write open in Acrobat (#774, fixed by #836) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 872c524870..4f376cfb7f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,7 +2,7 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) -> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2, and Export ▸ Images reads JBIG2 images; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -257,7 +257,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Symbol coding not yet. Estimates unchanged. | +| 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Export ▸ Images reads JBIG2 images (`hayro-jbig2`, with `/JBIG2Globals`). Symbol coding not yet. Estimates unchanged. | | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index be8c1b904e..7d66616ef7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7164,8 +7164,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; a JBIG2 page of another width is skipped); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" From b79fb490c2368ffc98efde1336bd27c36e164f91 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 20:03:55 +0100 Subject: [PATCH 47/82] M11: Persian and Indonesian for "JBIG2 (lossless)" fa.tsv lacked the row: Persian came to main after it was written. It goes after "CCITT Group 4", worded like the Arabic and Kurdish rows. id.tsv had it as an English copy beside rows it translates; it now says "tanpa kehilangan", as de, tr and vi translate "lossless". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/ui-egui/src/i18n/fa.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 09a2e21689..961307a35c 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -596,6 +596,7 @@ Grayscale Images تصاویر خاکستری Monochrome Images تصاویر تک‌رنگ CCITT Group 4 CCITT گروه ۴ + JBIG2 (lossless) JBIG2 (بدون اتلاف) Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به for images above برای تصاویر بالا‌تر از Compression فشرده‌سازی diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1783118fff..d737f093bd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2057,7 +2057,7 @@ comment menu Edit Edit Grayscale Images Gambar Skala Abu-abu Monochrome Images Gambar Monokrom CCITT Group 4 CCITT Grup 4 - JBIG2 (lossless) JBIG2 (lossless) + JBIG2 (lossless) JBIG2 (tanpa kehilangan) Green Hijau Green on black Hijau di atas hitam H / Space (hold) H / Space (tahan) From b2922056f4a9e2da1f3cd50097322167465beb88 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 12:44:00 +0100 Subject: [PATCH 48/82] =?UTF-8?q?M11:=20Export=20=E2=96=B8=20Images=20read?= =?UTF-8?q?s=20an=20image's=20/DecodeParms=20given=20by=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export ▸ Images and Save Image As decoded an image with Stream::decoded, which reads /DecodeParms only when it is direct. An image whose PNG predictor came by reference (the dictionary, an entry of an array, or the numbers inside) kept its row tags in the samples, and the picture came out shifted. The same was true of an indexed image's lookup table given as a stream, and of a JBIG2 image's /JBIG2Globals stream, which then couldn't be read, so the image was skipped. All three now decode through Document::with_direct_parms (#4304), as the optimizer does, and a test exports each. Found by CodeRabbit on #4805; the image decode was already this way on main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/create/src/extract.rs | 19 +++++---- crates/create/src/tests.rs | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 36825e6ad6..a1aba3f784 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -100,13 +100,15 @@ fn filters(s: &Stream) -> Vec> { } } -/// The samples of image `s`, decoded: through its filters, or, for a JBIG2 image (its only -/// filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples each -/// starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given +/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor +/// given so would otherwise be lost), or, for a JBIG2 image +/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples +/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). fn decoded(doc: &Document, s: &Stream) -> Result, String> { match filters(s).as_slice() { [f] if f == b"JBIG2Decode" => jbig2(doc, s), - _ => s.decoded().map_err(|e| e.to_string()), + _ => doc.with_direct_parms(s).decoded().map_err(|e| e.to_string()), } } @@ -129,7 +131,7 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict { /// decoding it without them. fn jbig2(doc: &Document, s: &Stream) -> Result, String> { let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { - Some(Object::Stream(g)) => Some(g.decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?), + Some(Object::Stream(g)) => Some(doc.with_direct_parms(g).decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?), _ => None, }; let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; @@ -498,9 +500,10 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => { - t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? - } + Some(Object::Stream(t)) => doc + .with_direct_parms(t) + .decoded_within(MAX_LOOKUP) + .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 8a861270db..7a1f92759a 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1339,6 +1339,84 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() { assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); } +/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms` +/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So +/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference, +/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a +/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture +/// came out shifted. +#[test] +fn images_whose_predictor_is_given_by_reference_export_as_their_picture() { + // PNG predictor parameters for `columns` one-byte samples a row. + let parms = |columns: i64| { + let mut p = Dict::new(); + p.set(b"Predictor".to_vec(), Object::Int(15)); + p.set(b"Colors".to_vec(), Object::Int(1)); + p.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + p.set(b"Columns".to_vec(), Object::Int(columns)); + p + }; + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let grey: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect(); + for form in ["a reference", "an array of a reference", "numbers by reference"] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let decode_parms = match form { + "numbers by reference" => { + let mut p = parms(4); + for key in [&b"Predictor"[..], b"Columns"] { + let v = p.get(key).cloned().unwrap(); + p.set(key.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(p) + } + "a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]), + }; + d.set(b"DecodeParms".to_vec(), decode_parms); + Stream::from_raw(d, predicted.clone()) + }); + assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}"); + } + // An indexed image's lookup table, red then blue, as a stream behind a "None" row tag. + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut t = Dict::new(); + t.set(b"Filter".to_vec(), Object::name("FlateDecode")); + t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6))))); + let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255])))); + let mut d = image_dict(4, 1, "DeviceGray", 8); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)])); + Stream::from_raw(d, vec![0, 1, 0, 1]) + }); + let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]); + assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference"); + // A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder + // ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the + // image is skipped. + let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64))))); + let tagged: Vec = [0].iter().chain(&segment).copied().collect(); + let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged)))); + let mut decode_parms = Dict::new(); + decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms)); + Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap()) + }); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect(); + assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference"); +} + /// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in /// an array, so globals that can't be read skip the image rather than being ignored. Rows past the From e6e34bfa8d1c7d39b8d42d19db6a8fc3ce9d563c Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 12:44:00 +0100 Subject: [PATCH 49/82] M11: only Group 4 is limited to rows of 32,767 samples (README, parity) The optimizer README's and optimize.mono-compression's "not yet" lists said rows wider than 32,767 samples aren't stored. That is Group 4's limit (MAX_G4_COLUMNS); JBIG2 takes rows of up to 1,048,576 (MAX_JBIG2_COLUMNS). Found by CodeRabbit on #4805. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/optimize/README.md | 11 ++++++----- parity/acrobat-features.toml | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 5162cdbbaf..9c1715ad81 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -42,11 +42,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going than Group 4. It is lossless: the samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: - JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and `/Mask` images, and rows wider - than 32,767 samples. Redaction doesn't decode Group 4 yet: a mark over part of an image stored - this way removes the whole image (nothing leaks). Nor does anything that exports an image: - Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML - export leave the picture out. + JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and + `/Mask` images, and Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to + 1,048,576). Redaction doesn't decode Group 4 yet: a mark over part of an image stored this way + removes the whole image (nothing leaks). Nor does anything that exports an image: Export ▸ + Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave + the picture out. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7d66616ef7..e6688347af 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, rows wider than 32,767 samples. Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From 881c4150e94ec3e638107aab4d228b7079d23528 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 13:26:24 +0100 Subject: [PATCH 50/82] =?UTF-8?q?M11:=20Export=20=E2=96=B8=20Images=20refu?= =?UTF-8?q?ses=20a=20JBIG2=20page=20larger=20than=20it=20exports,=20before?= =?UTF-8?q?=20decoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export checked an image's /Width × /Height against its 100-million- pixel budget, and the JBIG2 page's width against /Width, but not the page's height. The decoder decodes the whole page, rows past the image's height too, and takes pages of up to about 2^28 pixels: a 20,000 × 4 image over a 20,000 × 10,000 JBIG2 page cost 200 million pixels of decoding to export four rows. The page itself is now held to the budget before it is decoded. Found by CodeRabbit on #4320. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/create/src/extract.rs | 7 ++++++- crates/create/src/tests.rs | 20 +++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index a1aba3f784..b46f74570f 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,7 +126,9 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict { } /// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller -/// checked against the export's size limit; rows past the image's height are dropped, and too few +/// checked against the export's size limit, and within that limit itself: the decoder decodes the +/// whole page, rows past the image's height too, so a small `/Height` over a far taller page +/// would cost more than the limit allows. Rows past the image's height are dropped, and too few /// rows are refused by the caller. Globals that can't be read refuse the image, rather than /// decoding it without them. fn jbig2(doc: &Document, s: &Stream) -> Result, String> { @@ -138,6 +140,9 @@ fn jbig2(doc: &Document, s: &Stream) -> Result, String> { if s.dict.int(b"Width") != Some(i64::from(image.width())) { return Err("the JBIG2 image's width isn't the image's width".into()); } + if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { + return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); + } let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); let mut rows = Rows::new(image.width() as usize, height); image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 7a1f92759a..9b54787fc9 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1421,7 +1421,8 @@ fn images_whose_predictor_is_given_by_reference_export_as_their_picture() { /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in /// an array, so globals that can't be read skip the image rather than being ignored. Rows past the /// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, -/// or data that isn't JBIG2, skips the image. +/// or larger than an exported image may be (however small the image's `/Height`), or data that +/// isn't JBIG2, skips the image. #[test] fn jbig2_images_export_as_their_picture() { let (w, h) = (203i64, 41i64); @@ -1486,6 +1487,23 @@ fn jbig2_images_export_as_their_picture() { for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); } + // The same stream with its page information (the first segment's data, after its 11-byte + // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the + // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page + // (it takes pages of up to about 2^28 pixels), so it is refused first. + let mut large = jbig2.clone(); + large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); + large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. + for (rows, refused) in [(5_000u32, false), (5_001, true)] { + large[15..19].copy_from_slice(&rows.to_be_bytes()); + let doc = page(20_000, 4, &large, None, false); + assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); + if !refused { + assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); + } + } // Damaged data never panics. for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); From fe5e8f44317d93ffe956fdcb785aa63e556f406d Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 13:54:56 +0100 Subject: [PATCH 51/82] M11: the optimizer reads a catalog /Version given by reference When the optimizer stores an image as JBIG2 (PDF 1.4), it raises the catalog's /Version for an older file. It read /Version only when it was direct: a catalog saying 1.7 by reference looked like no version at all, and was set to /1.4, lowering the document. It is now resolved first. Found in pre-push review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/optimize/src/images.rs | 7 ++++--- crates/optimize/tests/optimize.rs | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8b2f30a8f0..cf668d0fbf 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -674,12 +674,13 @@ pub(crate) fn run( } /// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 -/// or more already, gets `/Version /1.4` in its catalog (ISO 32000-2 §7.7.2). A full save with -/// object streams writes a 1.5 header anyway. +/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 +/// §7.7.2). A full save with object streams writes a 1.5 header anyway. fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..]; let Some(root) = doc.root() else { return Ok(()) }; - let catalog = doc.get(root).as_dict().and_then(|c| c.name(b"Version").map(<[u8]>::to_vec)); + let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); + let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?; } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index aaebc5f4f5..9a0990286e 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -649,6 +649,31 @@ fn black_and_white_images_become_jbig2() { assert!(render(&jbig2) == before, "the page looks the same"); } +/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says +/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one +/// whose catalog says 1.3 by reference gets 1.4. +#[test] +fn a_catalog_version_given_by_reference_is_read() { + for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q"); + let version = doc.add(Object::name(catalog)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap(); + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let r = optimize(&mut doc, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 1); + let root = doc.root().unwrap(); + let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap(); + assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference"); + } +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); From d416d6a36e8c8b8a748851f0bafb150cd93a1420 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 13:55:01 +0100 Subject: [PATCH 52/82] M11: say how export and redaction treat JBIG2 images they can't read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export ▸ Images skips an image whose JBIG2 page has another width or more pixels than an export takes; a JBIG2 stencil mask refused the same way is left out and the image exported opaque, as JPX and CCITT masks are, which the export test now checks. The README and parity note said only "is skipped". And redaction doesn't decode JBIG2 or Group 4 yet, so a mark over part of an image the optimizer stored either way removes the whole image; the optimizer README and parity note now say so (#4384 adds Group 4). Found in pre-push review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/create/README.md | 4 +++- crates/create/src/tests.rs | 6 ++++++ crates/optimize/README.md | 8 ++++---- parity/acrobat-features.toml | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/create/README.md b/crates/create/README.md index f49e53dad4..05395656a3 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -26,7 +26,9 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, -as `JBIG2Decode` gives them: black as 0. A JBIG2 page of another width than the image is skipped. +as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more +pixels than an exported image may have, is skipped; such a mask is left out and the image exported opaque, as JPX +and CCITT masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9b54787fc9..98fbfd7eee 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1495,6 +1495,12 @@ fn jbig2_images_export_as_their_picture() { large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. + for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { + let rgba = exported_rgba(&page(width, height, mask, None, true)).1; + assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); + assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); + } // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. for (rows, refused) in [(5_000u32, false), (5_001, true)] { large[15..19].copy_from_slice(&rows.to_be_bytes()); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 9c1715ad81..a1c47697b5 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -44,10 +44,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and `/Mask` images, and Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to - 1,048,576). Redaction doesn't decode Group 4 yet: a mark over part of an image stored this way - removes the whole image (nothing leaks). Nor does anything that exports an image: Export ▸ - Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave - the picture out. + 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image + removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image + reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, + PowerPoint and HTML export leave the picture out. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e6688347af..84f7981676 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7165,7 +7165,7 @@ status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; a JBIG2 page of another width is skipped); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode Group 4 yet (a mark over part of such an image removes it whole; nothing leaks), nor does anything that exports an image: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From c748d2183ddd80ba5515bff948176267864ab909 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 14:25:11 +0100 Subject: [PATCH 53/82] M11: doc_optimize reports a catalog /Version given by reference With compatibility retain, doc_optimize reports the later of the header's version and the catalog's /Version. It read /Version only when it was direct, so a catalog saying 1.6 by reference was reported as the header's 1.4. It is now resolved first, as the optimizer reads it (a88c8afb). The bug was already on main; found in pre-push review as a sibling of that fix. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/automation/src/documents.rs | 4 +++- crates/automation/tests/it/automation.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 880179b1eb..83790afed7 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -373,7 +373,9 @@ impl Automation { let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; let root = doc.root()?; - doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + // Through a reference too, as the optimizer reads it. + let version = doc.get(root).as_dict()?.get(b"Version")?.clone(); + doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned) }) .flatten(); let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 835db4c755..f41519aff7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -6293,6 +6293,15 @@ fn optimizing_through_tools() { let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap(); let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false })); assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}"); + // The same with the catalog's /Version given by reference: read through it. + let v16 = cos14.add(pdfcraft_cos::Object::name("1.6")); + cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap(); + let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap(); + let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap(); + let by_ref = + ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false })); + assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}"); assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( From 71875ce5a8d20a87c9d3fa1ae567f9f13797faa4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 18:14:02 +0100 Subject: [PATCH 54/82] M11: Copy Image reads JBIG2; the parity table no longer lists lossless JBIG2 as missing Copy Image and Copy Image in Original Format go through Export's decoder, which reads JBIG2 now, so the view.copy-image parity note no longer lists JBIG2 as missing. docs/target-app-parity.md's optimize row lists JBIG2 symbol coding and lossy JBIG2 as missing instead of monochrome JBIG2, with a status line and a revision row. From reviews with the pdfcraft-review skill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- docs/target-app-parity.md | 4 +++- parity/acrobat-features.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d790e98178..22832f65f2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (K Optimize and standards no longer lists lossless JBIG2 as missing; scores and estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (`optimize.mono-compression` partial (CCITT Group 4): K Optimize and standards 15 shipped and 11 partial of 58, 35.3%; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497); estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) @@ -209,7 +210,7 @@ full parity in that area. | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | -| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, monochrome JBIG2 and downsampling | +| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, JBIG2 symbol coding and lossy JBIG2, monochrome downsampling | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report, side by side, Text/Visual filter). Missing: most Preferences pages, Acrobat's finer compare filters, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | @@ -295,6 +296,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | K Optimize and standards no longer lists lossless JBIG2 as missing (a generic region, through the Optimizer's Monochrome Images); symbol coding and lossy JBIG2 still are. Scores and estimates unchanged. | | 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. | | 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | | 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 84f7981676..69a05410de 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1599,7 +1599,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/viewer.rs::a_clicked_picture_is_selected_and_copied_in_its_original_format", "crates/ui-egui/tests/it/viewer.rs::right_click_on_a_picture_copies_it_at_full_size", "crates/ui-egui/tests/it/viewer.rs::a_picture_inside_form_artwork_under_text_is_selected_and_copied", "crates/engine/src/tests.rs::copy_image_finds_a_picture_inside_form_artwork_under_text"] -notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/JBIG2/CCITT images, multi-format clipboard (file and bitmap at once), the web build" +notes = "Select tool: a click selects a raster image (also inside Form artwork or under text) and ⌘C copies it as a file in its original format (JPEG as stored, else PNG); right-click ▸ Copy Image copies its pixels at full resolution. Missing: inline images (BI/ID/EI), JPEG 2000/CCITT images, JBIG2 inside other filters, multi-format clipboard (file and bitmap at once), the web build" [[feature]] id = "view.rulers" From 4cfd592730d3ff74ac6b5d30f02685d487d21e05 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 23 Oct 2026 18:56:40 +0200 Subject: [PATCH 55/82] M6: calculation checks: a formula that reads a field the form doesn't have, and a script that builds the names it reads calculation_order flags MissingField (a field deleted from under a sum, a typo in notation, a script's getField; a group's name and a widget's number count as there) and BuiltNames (a getField or AFSimple_Calculate list built at run time, found as af::Site::Built in the same pass as the literal names). form_calculation_order gives each field's formula and the two new issues; form_fields shows a custom Calculate script; the Calculation order page explains both in its row tooltips. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- ROADMAP.md | 1 + crates/automation/src/forms.rs | 41 ++++++++++++++---- crates/automation/src/tools.rs | 4 +- crates/automation/tests/it/automation.rs | 33 ++++++++++++++ crates/forms/README.md | 5 ++- crates/forms/src/af.rs | 2 +- crates/forms/src/af/calc.rs | 53 ++++++++++++++++------- crates/forms/src/af/tests.rs | 31 +++++++++++++ crates/forms/src/calc_order.rs | 38 +++++++++++++++- crates/forms/src/tests.rs | 39 +++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 3 ++ crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ro.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/sq.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/tools/calc_order_ui.rs | 6 +++ docs/roadmap.md | 2 + parity/acrobat-features.toml | 8 ++-- 42 files changed, 288 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f5de8044e1..2c86653a4d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,6 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 1c1e3c3371..1dec77bf03 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -116,6 +116,9 @@ fn calculate_arg(v: &Value) -> Result { if let Some(n) = o.get("notation").and_then(Value::as_str) { return Ok(Calculate::Notation(n.to_string())); } + if o.contains_key("script") { + return Err(bad("a custom calculate script is set with form_set_script (event: calculate)")); + } let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { "sum" => CalcOp::Sum, "product" => CalcOp::Product, @@ -132,6 +135,17 @@ fn calculate_arg(v: &Value) -> Result { Ok(Calculate::Simple { op, fields }) } +/// A field's calculation as `form_fields` and `form_calculation_order` show it, in +/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one. +fn calculate_json(f: &FormField) -> Option { + match (&f.actions.calculate, &f.actions.scripts.calculate) { + (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op.code().to_lowercase(), "fields": fields })), + (Calculate::Notation(n), _) => Some(json!({ "notation": n })), + (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::None, None) => None, + } +} + fn format_json(f: &Format) -> Value { match f { Format::None => Value::Null, @@ -220,14 +234,8 @@ impl Automation { if let Validate::Range { min, max } = &f.actions.validate { o.insert("validate".into(), json!({ "min": min, "max": max })); } - match &f.actions.calculate { - Calculate::Simple { op, fields } => { - o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields })); - } - Calculate::Notation(n) => { - o.insert("calculate".into(), json!({ "notation": n })); - } - Calculate::None => {} + if let Some(c) = calculate_json(f) { + o.insert("calculate".into(), c); } if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); @@ -653,15 +661,30 @@ impl Automation { }; let order = self.doc(a)?.calculation_order(); let issue = |i: &CalculationIssue| match i { + CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }), CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }), CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }), CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }), + CalculationIssue::BuiltNames => json!({ "issue": "built_names" }), }; + let doc = self.doc(a)?; + let mut by_name: std::collections::HashMap<&str, &FormField> = std::collections::HashMap::with_capacity(doc.form.len()); + for f in doc.form.iter() { + by_name.entry(f.name.as_str()).or_insert(f); + } out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>()); out["fields"] = order .fields .iter() - .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() })) + .map(|f| { + json!({ + "name": f.name, + "formula": by_name.get(f.name.as_str()).and_then(|x| calculate_json(x)), + "reads": f.reads, + "listed": f.listed, + "issues": f.issues.iter().map(issue).collect::>(), + }) + }) .collect(); out["extra"] = json!(order.extra); out["fixable"] = json!(order.fixable()); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 53c4582af9..b41e114442 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -391,7 +391,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -502,7 +502,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), - t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") + t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") .cmd("form.calculation_order") .with(schema( json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 01d0d1eb24..22b5cd17e2 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5058,6 +5058,39 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +/// The calculation order through the tool shows each formula, in form_set_props's shape or as a +/// script, and flags a read of a field the form doesn't have and a script that builds the names +/// it reads; form_fields shows a custom script too. +#[test] +fn the_calculation_order_shows_formulas_and_what_they_cannot_read() { + let dir = workdir("calcformulas"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;"; + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } })); + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }])); + assert_eq!(r["fields"][1]["reads"], json!(["sub"])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }])); + assert_eq!(r["fields"][2]["issues"], json!([])); + assert_eq!(r["fixable"], false, "neither is an order to fix"); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap(); + assert_eq!(tax["calculate"], json!({ "script": script })); + // A script is set with form_set_script, and form_set_props says so. + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": { "script": script } })); + assert!(e.to_string().contains("form_set_script"), "{e}"); +} + #[test] fn page_tab_orders_are_read_and_set_per_page_through_tools() { let dir = workdir("pagetabs"); diff --git a/crates/forms/README.md b/crates/forms/README.md index 93b65bd14e..3b5e46eee9 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -90,7 +90,10 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`, `af::calculation_reads`; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` - entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological + entries that calculate nothing. Two issues are about the formula itself: it reads a field the + form doesn't have (`MissingField`; the field of that name, a field under it and a widget of one, + `price.0`, all count as there), or its script builds names at run time (`BuiltNames`, + `af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological sort that keeps the current order wherever it can and leaves loops as they are. `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of the order; one whose calculation changes keeps its place. diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index ee4aa18e3c..00cfc99d12 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -19,7 +19,7 @@ mod script; #[cfg(test)] use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; -pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation}; +pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; use script::{Arg, call}; pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index b3a1524a4f..958b5d4d04 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -206,6 +206,10 @@ enum Site { Commas(String), /// A name in the simplified field notation of a `BVCALC` comment. Notation(String), + /// A field read by a name built at run time: a `getField` whose argument is more than one + /// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't + /// written out. Which field it is isn't known. + Built, } impl Site { @@ -213,6 +217,7 @@ impl Site { match self { Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + Site::Built => Vec::new(), } } @@ -229,14 +234,16 @@ impl Site { js_str(&parts.join(",")) }) } + Site::Built => None, } } } /// Where a Calculate script names fields, in order: the simplified field notation in its comment /// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name +/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or +/// `AFSimple_Calculate`. fn calculation_sites(js: &str) -> Vec<(Range, Site)> { let mut sites = Vec::new(); let (tokens, comments) = js_tokens(js); @@ -255,35 +262,45 @@ fn calculation_sites(js: &str) -> Vec<(Range, Site)> { Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), _ => None, }; + let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built)); for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); + if ident(k, "getField") && punct(k + 1, '(') { + match string(k + 2) { + Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))), + _ => sites.extend(built(k)), + } } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) { + continue; + } + if !(string(k + 2).is_some() && punct(k + 3, ',')) { + sites.extend(built(k)); continue; } if let Some((range, list)) = string(k + 4) { sites.push((range, Site::Commas(list))); continue; } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 + // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string, + // which has to close the list for the list to be written out. Nothing else is read, so no + // token is read for two calls. + let (first, close) = if punct(k + 4, '[') { + (k + 5, ']') } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 + (k + 7, ')') } else { + sites.extend(built(k)); continue; }; for (range, token) in tokens.iter().skip(first) { match token { Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), Js::Punct(',') => {} - _ => break, + Js::Punct(c) if *c == close => break, + _ => { + sites.extend(built(k)); + break; + } } } } @@ -315,6 +332,12 @@ pub fn calculation_reads(js: &str) -> Vec { unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) } +/// Whether a Calculate script also reads fields by names it builds at run time +/// (`getField("row" + i)`), which [`calculation_reads`] can't list. +pub fn reads_built_names(js: &str) -> bool { + calculation_sites(js).iter().any(|(_, site)| *site == Site::Built) +} + impl Calculate { /// The fields it reads, each once, in the order it first names them. A name also stands for /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index ff25e06968..5198fcc5ad 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -156,6 +156,37 @@ fn a_regex_after_a_condition_is_not_read_as_code() { assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); } +/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that +/// isn't written out, reads a field whose name isn't known until the script runs. +#[test] +fn names_built_at_run_time_are_noticed() { + for js in [ + "event.value = getField(\"row\" + i).value;", + "event.value = this.getField(name).value;", + "AFSimple_Calculate(\"SUM\", list);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", b));", + "AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));", + "AFSimple_Calculate(op, new Array(\"a\"));", + ] { + assert!(reads_built_names(js), "{js}"); + } + for js in [ + "event.value = getField(\"a\").value + getField('b').value;", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));", + "AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);", + "AFSimple_Calculate(\"SUM\", \"a, b\");", + "// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;", + "/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;", + ] { + assert!(!reads_built_names(js), "{js}"); + } + // The names it does know are still read and renamed. + let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));"; + assert_eq!(calculation_reads(js), ["a"]); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\""))); +} + /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in /// one another's field lists, don't make a rename read the rest of the script again for each. #[test] diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index 4e7f0309e9..c4a1b5bd37 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -26,9 +26,12 @@ pub struct CalculatedField { pub issues: Vec, } -/// Something wrong with a calculated field's place in the calculation order. +/// Something wrong with a calculated field's formula or its place in the calculation order. #[derive(Clone, Debug, PartialEq)] pub enum CalculationIssue { + /// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft, + /// or mistyped), so the formula gets no value there. + MissingField { field: String }, /// It reads `field`, which calculates after it, so it uses that field's previous value. RunsTooEarly { field: String }, /// Its formula reads its own result: it reads `fields`, which read it back (itself, when it @@ -37,6 +40,9 @@ pub enum CalculationIssue { /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers /// may not calculate it at all. NotInOrder, + /// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those + /// aren't in `reads`, so the order isn't checked against them. + BuiltNames, } /// A form's calculation order and what is wrong with it. @@ -152,11 +158,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .collect(); let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); let component = components(&links); + let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + all.sort_unstable(); let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); let mut out = Vec::with_capacity(calculated.len()); for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() { let own = component.get(k).copied(); - let mut issues = Vec::new(); + let mut issues: Vec = + reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect(); let read = links.get(k).map(Vec::as_slice).unwrap_or_default(); for &j in read { if j > k && component.get(j).copied() != own { @@ -172,12 +181,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { if !listed { issues.push(CalculationIssue::NotInOrder); } + if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) { + issues.push(CalculationIssue::BuiltNames); + } out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); } let fixed = fixed_order(&links, &component); CalculationOrder { fields: out, extra, fixed } } +/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name, a +/// field under it (`row` for `row.1`), or a widget of one (`price.0`, as `getField` numbers them). +fn found(names: &[&str], name: &str) -> bool { + let has = |n: &str| names.binary_search(&n).is_ok(); + let prefix = format!("{name}."); + let under = names.partition_point(|n| *n < prefix.as_str()); + let widget = name.rsplit_once('.').is_some_and(|(base, i)| !i.is_empty() && i.bytes().all(|b| b.is_ascii_digit()) && has(base)); + has(name) || names.get(under).is_some_and(|n| n.starts_with(&prefix)) || widget +} + /// For each calculated field (by its place in `names`), the places of the calculated fields it /// reads, sorted. A read name finds the field of that name and the fields under it, as /// [`crate::recalculate`] looks values up. @@ -383,6 +405,18 @@ mod tests { assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); } + #[test] + fn a_read_name_finds_a_field_a_group_or_a_widget() { + let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; + names.sort_unstable(); + for name in ["price", "row", "row.1", "rows", "price.0", "price.12"] { + assert!(found(&names, name), "{name}"); + } + for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "total.0.1", ""] { + assert!(!found(&names, name), "{name}"); + } + } + /// Two thousand formulas each reading a group of two thousand fields would be four million /// reads; the checks stop at their cap. #[test] diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ac414af2ae..6f848f9628 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1711,6 +1711,45 @@ fn the_calculation_order_is_checked_fixed_and_kept() { assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); } +/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it +/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a +/// script that builds the names it reads. Neither is an order Fix order can fix. +#[test] +fn missing_fields_and_built_names_are_flagged() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{BuiltNames, MissingField}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); + set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"qty\").value + getField(\"ghost\").value;")).unwrap(); + set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); + let issues = |doc: &Document| { + let order = calculation_order(doc, &fields(doc)); + assert!(!order.fixable(), "nothing Fix order can fix"); + order.fields.into_iter().map(|f| (f.name, f.issues)).collect::>() + }; + let expected = |sum: Vec<_>| { + vec![ + ("sum".to_owned(), sum), + ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), + ("script".to_owned(), vec![MissingField { field: "ghost".into() }]), + ("built".to_owned(), vec![BuiltNames]), + ] + }; + assert_eq!(issues(&doc), expected(vec![])); + delete_field(&mut doc, "gone").unwrap(); + assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing"); + assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }])); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3e6d735523..0d429f657c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -541,6 +541,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. يقرأ {names} التي تقرؤه بدورها. لا يمكن لأي ترتيب إصلاح الحلقة: غيّر إحدى الصيغ. Not in order خارج الترتيب Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. غير موجود في ترتيب الحساب المحفوظ في ملف PDF: قد لا تحسبه برامج عرض PDF الأخرى. + Reads {name}, which the form doesn't have, so it gets no value there. يقرأ {name}، وهو غير موجود في النموذج، لذا لا يحصل منه على أي قيمة. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. يقرأ سكربته أيضًا حقولًا يبني أسماءها أثناء التشغيل، لذا لا يمكن التحقق من هذه القراءات. Back to Prepare a form العودة إلى تجهيز نموذج No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. لا يحسب أي حقل في هذا النموذج قيمته بعد. امنح حقلًا عملية حساب في علامة التبويب «الحساب» ضمن خصائصه، وسيظهر هنا. {names} and {last} {names} و{last} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index ec0339f18a..2ef342e51b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -202,6 +202,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Чете {names}, които на свой ред го четат. Никой ред не може да поправи цикъл: променете една от формулите. Not in order Извън реда Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Липсва в реда на изчисляване, записан в PDF файла: други програми за PDF може да не го изчисляват. + Reads {name}, which the form doesn't have, so it gets no value there. Чете {name}, което го няма във формуляра, затова не получава стойност оттам. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Скриптът му чете и полета, чиито имена съставя по време на изпълнение, затова тези четения не могат да бъдат проверени. Back to Prepare a form Назад към „Подготви формуляр“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Все още нито едно поле в този формуляр не изчислява стойността си. Задайте изчисление на поле в раздела „Изчисляване“ на свойствата му и то ще се покаже тук. {names} and {last} {names} и {last} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0cacae3c2c..dda1208dad 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2720,6 +2720,8 @@ theme Appearance Aspecte Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Not in order Not in order Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Back to Prepare a form Back to Prepare a form No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. {names} and {last} {names} and {last} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ed6d051b57..3c38493305 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -625,6 +625,8 @@ key Space Mezerník Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Čte {names}, která ho čtou zpět. Žádné pořadí smyčku neopraví: změňte jeden ze vzorců. Not in order Mimo pořadí Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Chybí v pořadí výpočtů uloženém v PDF: jiné prohlížeče PDF ho možná nepočítají. + Reads {name}, which the form doesn't have, so it gets no value there. Čte {name}, které formulář nemá, takže odtud nedostane žádnou hodnotu. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jeho skript čte i pole, jejichž názvy sestavuje za běhu, takže tato čtení nelze zkontrolovat. Back to Prepare a form Zpět na Připravit formulář No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Žádné pole tohoto formuláře zatím nepočítá svou hodnotu. Zadejte poli výpočet na kartě Výpočet v jeho vlastnostech a zobrazí se zde. {names} and {last} {names} a {last} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8586a21d43..8d605a024b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -514,6 +514,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Liest {names}, die es wieder lesen. Keine Reihenfolge kann eine Schleife beheben: Ändere eine der Formeln. Not in order Nicht in der Reihenfolge Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Fehlt in der im PDF gespeicherten Berechnungsreihenfolge: Andere PDF-Viewer berechnen es womöglich nicht. + Reads {name}, which the form doesn't have, so it gets no value there. Liest {name}, das es im Formular nicht gibt, und erhält dort deshalb keinen Wert. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sein Skript liest auch Felder, deren Namen es erst beim Ausführen bildet. Diese Lesezugriffe lassen sich nicht prüfen. Back to Prepare a form Zurück zu „Formular vorbereiten“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Noch berechnet kein Feld dieses Formulars seinen Wert. Gib einem Feld auf dem Tab „Berechnen“ seiner Eigenschaften eine Berechnung, dann erscheint es hier. {names} and {last} {names} und {last} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e3eac77caa..3efb631ae1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1852,6 +1852,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Διαβάζει τα {names}, που με τη σειρά τους το διαβάζουν. Καμία σειρά δεν διορθώνει έναν βρόχο: αλλάξτε έναν από τους τύπους. Not in order Εκτός σειράς Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Λείπει από τη σειρά υπολογισμού που είναι αποθηκευμένη στο PDF: άλλα προγράμματα προβολής PDF ίσως δεν το υπολογίζουν. + Reads {name}, which the form doesn't have, so it gets no value there. Διαβάζει το {name}, που δεν υπάρχει στη φόρμα, οπότε δεν παίρνει τιμή από εκεί. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Η δέσμη ενεργειών του διαβάζει επίσης πεδία των οποίων τα ονόματα σχηματίζει κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν μπορούν να ελεγχθούν. Back to Prepare a form Επιστροφή στην Προετοιμασία φόρμας No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Κανένα πεδίο αυτής της φόρμας δεν υπολογίζει ακόμη την τιμή του. Δώστε σε ένα πεδίο υπολογισμό στην καρτέλα Υπολογισμός των ιδιοτήτων του και θα εμφανιστεί εδώ. {names} and {last} {names} και {last} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0d4e505a70..fb17254189 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -537,6 +537,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lee {names}, que a su vez lo leen. Ningún orden puede corregir un bucle: cambia una de las fórmulas. Not in order Fuera del orden Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Falta en el orden de cálculo guardado en el PDF: otros visores de PDF podrían no calcularlo. + Reads {name}, which the form doesn't have, so it gets no value there. Lee {name}, que no existe en el formulario, así que no obtiene ningún valor de ahí. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Su script también lee campos cuyos nombres construye mientras se ejecuta, así que esas lecturas no se pueden comprobar. Back to Prepare a form Volver a Preparar un formulario No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ningún campo de este formulario calcula todavía su valor. Dale a un campo un cálculo en la pestaña Calcular de sus propiedades y aparecerá aquí. {names} and {last} {names} y {last} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a542ee9b91..f35cb08d5a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2783,6 +2783,9 @@ theme Appearance Teema The signature's signed attributes have no message digest, so the signature does not cover the document. Allekirjoituksen allekirjoitetuista määritteistä puuttuu viestitiiviste, joten allekirjoitus ei kata asiakirjaa. The signature's signed attributes name a content type other than data, so it is not a signature of the document. Allekirjoituksen allekirjoitetut määritteet nimeävät muun sisältötyypin kuin data, joten se ei ole asiakirjan allekirjoitus. Remove {standard} identification Poista {standard}-tunniste +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61c8b5171d..e83249e394 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -514,6 +514,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lit {names}, qui le lisent en retour. Aucun ordre ne peut corriger une boucle : modifiez l'une des formules. Not in order Hors de l'ordre Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Absent de l'ordre de calcul enregistré dans le PDF : d'autres lecteurs PDF risquent de ne pas le calculer. + Reads {name}, which the form doesn't have, so it gets no value there. Lit {name}, qui n'existe pas dans le formulaire : aucune valeur n'en provient. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Son script lit aussi des champs dont il construit les noms à l'exécution : ces lectures ne peuvent pas être vérifiées. Back to Prepare a form Retour à Préparer un formulaire No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Aucun champ de ce formulaire ne calcule encore sa valeur. Donnez un calcul à un champ dans l'onglet Calculer de ses propriétés, et il apparaîtra ici. {names} and {last} {names} et {last} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index e9d627aca9..354b58ee89 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2721,6 +2721,8 @@ theme Appearance स्वरूप Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6b37b9169b..f64c76d51a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -512,6 +512,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Ezeket olvassa: {names}, amelyek visszaolvassák. Hurkot semmilyen sorrend nem javít meg: módosítsd az egyik képletet. Not in order Nincs a sorrendben Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Hiányzik a PDF-ben mentett számítási sorrendből: más PDF-megjelenítők talán nem számítják ki. + Reads {name}, which the form doesn't have, so it gets no value there. Ezt olvassa: {name}, amely nincs az űrlapon, így onnan nem kap értéket. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. A szkriptje olyan mezőket is olvas, amelyek nevét futás közben állítja össze, így ezek az olvasások nem ellenőrizhetők. Back to Prepare a form Vissza az Űrlap előkészítéséhez No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ennek az űrlapnak még egyik mezője sem számítja ki az értékét. Adj egy mezőnek számítást a tulajdonságai Számítás lapján, és itt megjelenik. {names} and {last} {names} és {last} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 292b336e10..0f62baf71f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2722,6 +2722,8 @@ theme Appearance Tampilan Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 20729bae6a..8c8208e706 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -538,6 +538,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Legge {names}, che a loro volta lo leggono. Nessun ordine può correggere un ciclo: modifica una delle formule. Not in order Fuori ordine Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manca nell'ordine di calcolo salvato nel PDF: altri visualizzatori PDF potrebbero non calcolarlo. + Reads {name}, which the form doesn't have, so it gets no value there. Legge {name}, che non esiste nel modulo, quindi da lì non riceve alcun valore. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Il suo script legge anche campi i cui nomi costruisce durante l'esecuzione, quindi queste letture non possono essere verificate. Back to Prepare a form Torna a Prepara un modulo No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nessun campo di questo modulo calcola ancora il proprio valore. Assegna un calcolo a un campo nella scheda Calcolo delle sue proprietà e comparirà qui. {names} and {last} {names} e {last} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ed4d43b6c..8a19ef110b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -541,6 +541,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} を読み取り、それらがこのフィールドを読み返します。循環はどの順序でも直せません。いずれかの数式を変更してください。 Not in order 順序にない Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF に保存された計算順序にありません。ほかの PDF ビューアーでは計算されない可能性があります。 + Reads {name}, which the form doesn't have, so it gets no value there. フォームにない {name} を読み取るため、そこからは値を得られません。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. スクリプトが実行中に名前を組み立てるフィールドも読み取るため、それらの読み取りは確認できません。 Back to Prepare a form 「フォームを作成」に戻る No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. このフォームには値を計算するフィールドがまだありません。フィールドのプロパティの[計算]タブで計算を設定すると、ここに表示されます。 {names} and {last} {names}、{last} diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9022c3591c..ff5bcd317b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -501,6 +501,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} өрістерін оқиды, олар оны қайта оқиды. Ешбір рет циклды түзете алмайды: формулалардың бірін өзгертіңіз. Not in order Ретте жоқ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF ішінде сақталған есептеу ретінде жоқ: басқа PDF қарау құралдары оны есептемеуі мүмкін. + Reads {name}, which the form doesn't have, so it gets no value there. {name} өрісін оқиды, бірақ ол пішінде жоқ, сондықтан одан мән алмайды. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Оның скрипті атауларын орындалу кезінде құрастыратын өрістерді де оқиды, сондықтан бұл оқуларды тексеру мүмкін емес. Back to Prepare a form Форманы дайындауға оралу No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Бұл формада мәнін есептейтін өріс әлі жоқ. Өріс сипаттарының «Есептеу» қойындысында оған есептеу беріңіз, сонда ол осында көрсетіледі. {names} and {last} {names} және {last} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 09b9cad9fa..139c016b5f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2721,6 +2721,8 @@ theme Appearance 모양 Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 43749f4ec8..e19abaf203 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -701,6 +701,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Skaito {names}, kurie savo ruožtu skaito jį. Jokia tvarka neištaisys ciklo: pakeiskite vieną iš formulių. Not in order Ne tvarkoje Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Trūksta PDF faile įrašytoje skaičiavimo tvarkoje: kitos PDF peržiūros programos gali jo neskaičiuoti. + Reads {name}, which the form doesn't have, so it gets no value there. Skaito {name}, kurio formoje nėra, todėl iš ten negauna jokios reikšmės. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jo scenarijus taip pat skaito laukus, kurių pavadinimus sudaro vykdymo metu, todėl šių skaitymų patikrinti negalima. Back to Prepare a form Grįžti į „Rengti formą“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nė vienas šios formos laukas dar neskaičiuoja savo reikšmės. Nustatykite laukui skaičiavimą jo savybių kortelėje „Skaičiavimas“, ir jis bus rodomas čia. {names} and {last} {names} ir {last} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 963d717584..aa3444289f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -536,6 +536,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Odczytuje {names}, które z kolei odczytują je. Żadna kolejność nie naprawi pętli: zmień jedną z formuł. Not in order Poza kolejnością Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Brakuje go w kolejności obliczeń zapisanej w pliku PDF: inne przeglądarki PDF mogą go nie obliczać. + Reads {name}, which the form doesn't have, so it gets no value there. Odczytuje {name}, którego nie ma w formularzu, więc nie dostaje stamtąd żadnej wartości. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jego skrypt odczytuje też pola, których nazwy tworzy w trakcie działania, więc tych odczytów nie da się sprawdzić. Back to Prepare a form Wróć do „Przygotuj formularz” No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Żadne pole tego formularza nie oblicza jeszcze swojej wartości. Nadaj polu obliczenie na karcie Obliczenia w jego właściwościach, a pojawi się tutaj. {names} and {last} {names} i {last} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c34ae83bc5..5ca0e2fb60 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -159,6 +159,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lê {names}, que o leem de volta. Nenhuma ordem resolve um ciclo: altere uma das fórmulas. Not in order Fora da ordem Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Ausente da ordem de cálculo salva no PDF: outros leitores de PDF podem não calculá-lo. + Reads {name}, which the form doesn't have, so it gets no value there. Lê {name}, que não existe no formulário, então não recebe nenhum valor dali. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Seu script também lê campos cujos nomes monta durante a execução, então essas leituras não podem ser verificadas. Back to Prepare a form Voltar para Preparar um formulário No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nenhum campo deste formulário calcula seu valor ainda. Dê um cálculo a um campo na aba Calcular das propriedades dele, e ele aparecerá aqui. {names} and {last} {names} e {last} diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 78d2298b93..cf1c74ad4d 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2723,6 +2723,8 @@ theme Appearance Aspect Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Citește {names}, care îl citesc la rândul lor. Nicio ordine nu poate rezolva o buclă: modificați una dintre formule. Not in order În afara ordinii Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Lipsește din ordinea de calcul salvată în PDF: alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze. + Reads {name}, which the form doesn't have, so it gets no value there. Citește {name}, care nu există în formular, deci nu primește nicio valoare de acolo. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Scriptul său citește și câmpuri ale căror nume le construiește în timpul rulării, deci aceste citiri nu pot fi verificate. Back to Prepare a form Înapoi la Pregătire formular No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Niciun câmp din acest formular nu își calculează încă valoarea. Adăugați un calcul unui câmp în fila Calcul din proprietățile lui și va apărea aici. {names} and {last} {names} și {last} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 274232d586..acfc72a2a0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -534,6 +534,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читает {names}, которые в свою очередь читают его. Никакой порядок не исправит цикл: измените одну из формул. Not in order Вне порядка Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Отсутствует в порядке вычислений, сохранённом в PDF: другие программы просмотра PDF могут его не вычислять. + Reads {name}, which the form doesn't have, so it gets no value there. Читает {name}, которого нет в форме, поэтому не получает оттуда значения. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Его скрипт также читает поля, имена которых составляет во время выполнения, поэтому эти чтения нельзя проверить. Back to Prepare a form Назад к «Подготовить форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ни одно поле этой формы пока не вычисляет своё значение. Задайте полю вычисление на вкладке «Вычислить» его свойств, и оно появится здесь. {names} and {last} {names} и {last} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 420e6a5996..19ff9c059b 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1027,6 +1027,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Bere {names}, ki ga berejo nazaj. Noben vrstni red ne popravi zanke: spremenite eno od formul. Not in order Ni v vrstnem redu Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manjka v vrstnem redu izračunov, shranjenem v PDF-ju: drugi pregledovalniki PDF ga morda ne izračunajo. + Reads {name}, which the form doesn't have, so it gets no value there. Bere {name}, ki ga v obrazcu ni, zato od tam ne dobi vrednosti. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Njegov skript bere tudi polja, katerih imena sestavi med izvajanjem, zato teh branj ni mogoče preveriti. Back to Prepare a form Nazaj na Pripravi obrazec No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nobeno polje tega obrazca še ne izračuna svoje vrednosti. Polju dodelite izračun na zavihku Izračunaj v njegovih lastnostih in prikazalo se bo tukaj. {names} and {last} {names} in {last} diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ba7c9dc5b3..ed552c8005 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2409,6 +2409,8 @@ animation effects Off Joaktiv Mark as artifact Mark as artifact Merge image fragments Merge image fragments Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Mobile Mobile Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7f339989ee..e058226539 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -518,6 +518,8 @@ comment menu Edit సవరించు Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names}ను చదువుతుంది, అవి దీన్ని తిరిగి చదువుతాయి. ఏ క్రమమూ లూప్‌ను సరిచేయదు: సూత్రాలలో ఒకదాన్ని మార్చండి. Not in order క్రమంలో లేదు Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDFలో సేవ్ చేసిన గణన క్రమంలో లేదు: ఇతర PDF వీక్షకులు దీన్ని లెక్కించకపోవచ్చు. + Reads {name}, which the form doesn't have, so it gets no value there. {name}ను చదువుతుంది, కానీ అది ఫారమ్‌లో లేదు, కాబట్టి దాని నుండి విలువ రాదు. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. దీని స్క్రిప్ట్ నడుస్తున్నప్పుడు పేర్లను కూర్చే ఫీల్డ్‌లను కూడా చదువుతుంది, కాబట్టి ఆ చదువులను తనిఖీ చేయలేము. Back to Prepare a form ‘ఫారాన్ని సిద్ధం చేయి’కి తిరిగి వెళ్లు No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. ఈ ఫారంలో ఏ ఫీల్డ్ ఇంకా తన విలువను గణించదు. ఫీల్డ్ లక్షణాలలోని గణన ట్యాబ్‌లో దానికి గణన ఇవ్వండి, అది ఇక్కడ కనిపిస్తుంది. {names} and {last} {names} మరియు {last} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a2ecaea179..0be6a69520 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -499,6 +499,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} alanlarını okur, onlar da bu alanı okur. Hiçbir sıra bir döngüyü düzeltemez: formüllerden birini değiştirin. Not in order Sırada değil Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF'te kayıtlı hesaplama sırasında yok: diğer PDF görüntüleyicileri bunu hesaplamayabilir. + Reads {name}, which the form doesn't have, so it gets no value there. Formda olmayan {name} alanını okur, bu yüzden oradan değer alamaz. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Komut dosyası, adlarını çalışırken oluşturduğu alanları da okur; bu okumalar denetlenemez. Back to Prepare a form Form hazırla'ya geri dön No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Bu formdaki hiçbir alan henüz değerini hesaplamıyor. Bir alana özelliklerinin Hesapla sekmesinde bir hesaplama verin, burada görünür. {names} and {last} {names} ve {last} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9c76868839..5d45a3f4b6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -539,6 +539,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читає {names}, які своєю чергою читають його. Жоден порядок не виправить цикл: змініть одну з формул. Not in order Поза порядком Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Відсутнє в порядку обчислень, збереженому в PDF: інші переглядачі PDF можуть його не обчислювати. + Reads {name}, which the form doesn't have, so it gets no value there. Читає {name}, якого немає у формі, тому не отримує звідти значення. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Його сценарій також читає поля, імена яких складає під час виконання, тому ці читання неможливо перевірити. Back to Prepare a form Назад до «Підготувати форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Жодне поле цієї форми поки не обчислює своє значення. Задайте полю обчислення на вкладці «Обчислення» його властивостей, і воно з’явиться тут. {names} and {last} {names} і {last} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 973ba870ea..2b51f445a3 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -697,6 +697,8 @@ theme Appearance Giao diện Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Đọc {names}, các trường này lại đọc ngược nó. Không thứ tự nào sửa được vòng lặp: hãy đổi một trong các công thức. Not in order Không có trong thứ tự Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Không có trong thứ tự tính toán lưu trong PDF: các trình xem PDF khác có thể không tính trường này. + Reads {name}, which the form doesn't have, so it gets no value there. Đọc {name}, nhưng biểu mẫu không có trường này, nên không nhận được giá trị nào từ đó. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Tập lệnh của nó còn đọc các trường có tên được tạo ra khi chạy, nên không thể kiểm tra các lần đọc đó. Back to Prepare a form Quay lại Chuẩn bị biểu mẫu No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Chưa có trường nào trong biểu mẫu này tự tính giá trị. Hãy đặt phép tính cho một trường ở thẻ Tính toán trong thuộc tính của nó, trường đó sẽ hiện ở đây. {names} and {last} {names} và {last} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d77c8f6425..c4aae05c8a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -208,6 +208,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 读取 {names},而它们又读取此字段。任何顺序都无法修正循环:请修改其中一个公式。 Not in order 不在顺序中 Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中保存的计算顺序缺少此字段:其他 PDF 查看器可能不会计算它。 + Reads {name}, which the form doesn't have, so it gets no value there. 读取表单中不存在的 {name},因此从中得不到值。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其脚本还会读取运行时才拼出名称的字段,因此无法检查这些读取。 Back to Prepare a form 返回准备表单 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表单中还没有计算自身值的字段。在字段属性的“计算”选项卡中为字段设置计算后,它会显示在这里。 {names} and {last} {names}和{last} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ce3b939f1d..82d6eb6a5d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -164,6 +164,8 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 讀取 {names},而它們又讀取此欄位。任何順序都無法修正迴圈:請修改其中一個公式。 Not in order 不在順序中 Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中儲存的計算順序缺少此欄位:其他 PDF 檢視器可能不會計算它。 + Reads {name}, which the form doesn't have, so it gets no value there. 讀取表單中不存在的 {name},因此從中得不到值。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其指令碼還會讀取執行時才組出名稱的欄位,因此無法檢查這些讀取。 Back to Prepare a form 返回準備表單 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表單中還沒有計算自身值的欄位。在欄位內容的「計算」索引標籤中為欄位設定計算後,它會顯示在這裡。 {names} and {last} {names}和{last} diff --git a/crates/ui-egui/src/tools/calc_order_ui.rs b/crates/ui-egui/src/tools/calc_order_ui.rs index 2b7a0c5f94..e02983ac5d 100644 --- a/crates/ui-egui/src/tools/calc_order_ui.rs +++ b/crates/ui-egui/src/tools/calc_order_ui.rs @@ -475,6 +475,9 @@ fn explained(issues: &[CalculationIssue]) -> String { issues .iter() .map(|i| match i { + CalculationIssue::MissingField { field } => { + crate::i18n::fmt(tl!("Reads {name}, which the form doesn't have, so it gets no value there."), &[("name", field)]) + } CalculationIssue::RunsTooEarly { field } => { crate::i18n::fmt(tl!("Reads {name}, which calculates later, so it shows an old value."), &[("name", field)]) } @@ -485,6 +488,9 @@ fn explained(issues: &[CalculationIssue]) -> String { CalculationIssue::NotInOrder => { tl!("Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.").to_owned() } + CalculationIssue::BuiltNames => { + tl!("Its script also reads fields whose names it builds as it runs, so those reads can't be checked.").to_owned() + } }) .collect::>() .join("\n") diff --git a/docs/roadmap.md b/docs/roadmap.md index 25205e92c1..6e929825ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M6: the calculation order flags a formula that reads a field the form doesn't have and a script that builds the names it reads; the tools show each formula; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -255,6 +256,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page; `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..766c439703 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4679,8 +4679,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties", "form.calculation_order"] tools = ["form_set_props", "form_fill", "form_calculation_order"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_a_group_or_a_widget", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. Each formula is also checked against the form: a read of a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's getField) and a script that builds the names it reads at run time are flagged, in form_calculation_order (which also gives each formula) and in the page's row tooltips. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] id = "form.event-order" @@ -5051,8 +5051,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af/tests.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af/tests.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af/tests.rs::a_token_ends_at_a_line_separator"] -notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed." +evidence = ["crates/ui-egui/tests/it/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/it/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/it/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/it/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name", "crates/forms/src/af/tests.rs::a_regex_after_a_condition_is_not_read_as_code", "crates/forms/src/af/tests.rs::a_line_comment_ends_at_any_line_terminator", "crates/forms/src/af/tests.rs::a_token_ends_at_a_line_separator", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read"] +notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. form_fields shows a custom Calculate script as {script}. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. A regular expression that starts a statement right after a `}` is still read as code, so a quote inside one can hide the getField calls after it from a rename and from the calculation order. Whether Acrobat does this is not observed." [[feature]] id = "form.props-selection-change" From feefeb8c95fd90bf636a12f07a4ca0ee4c94a64e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 23 Oct 2026 19:57:46 +0200 Subject: [PATCH 56/82] M6: Calculation order: a formula that reads a missing field is marked, not only explained on hover The row gets a red step and a "Missing field" caption, as a loop does; a red card names the formulas and links to the first, in place of the all-clear; a script that builds names gets a grey "Unchecked reads" caption and a note; the summary above the page lists a missing field among the reads and says so, and says when a script names fields as it runs. The name and formula lines of a row are sized by their own right-hand text, so a wide caption no longer cuts the name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- ROADMAP.md | 2 +- crates/ui-egui/src/i18n/ar.tsv | 9 ++ crates/ui-egui/src/i18n/bg.tsv | 9 ++ crates/ui-egui/src/i18n/ca.tsv | 9 ++ crates/ui-egui/src/i18n/cs.tsv | 9 ++ crates/ui-egui/src/i18n/de.tsv | 9 ++ crates/ui-egui/src/i18n/el.tsv | 9 ++ crates/ui-egui/src/i18n/es.tsv | 9 ++ crates/ui-egui/src/i18n/fi.tsv | 9 ++ crates/ui-egui/src/i18n/fr.tsv | 9 ++ crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 9 ++ crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 9 ++ crates/ui-egui/src/i18n/ja.tsv | 9 ++ crates/ui-egui/src/i18n/kk.tsv | 9 ++ crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/lt.tsv | 9 ++ crates/ui-egui/src/i18n/pl.tsv | 9 ++ crates/ui-egui/src/i18n/pt-br.tsv | 9 ++ crates/ui-egui/src/i18n/ro.tsv | 9 ++ crates/ui-egui/src/i18n/ru.tsv | 9 ++ crates/ui-egui/src/i18n/sl.tsv | 9 ++ crates/ui-egui/src/i18n/sq.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 9 ++ crates/ui-egui/src/i18n/tr.tsv | 9 ++ crates/ui-egui/src/i18n/uk.tsv | 9 ++ crates/ui-egui/src/i18n/vi.tsv | 9 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 9 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 9 ++ crates/ui-egui/src/tools/calc_order_ui.rs | 176 ++++++++++++++++++---- crates/ui-egui/tests/it/prepare.rs | 35 +++++ docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 34 files changed, 426 insertions(+), 34 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c86653a4d..795dff2921 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -205,7 +205,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. -- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. +- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 0d429f657c..bc2091e029 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -543,6 +543,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. غير موجود في ترتيب الحساب المحفوظ في ملف PDF: قد لا تحسبه برامج عرض PDF الأخرى. Reads {name}, which the form doesn't have, so it gets no value there. يقرأ {name}، وهو غير موجود في النموذج، لذا لا يحصل منه على أي قيمة. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. يقرأ سكربته أيضًا حقولًا يبني أسماءها أثناء التشغيل، لذا لا يمكن التحقق من هذه القراءات. + Missing field حقل مفقود + Unchecked reads قراءات غير مفحوصة + {field} reads fields named as it runs يقرأ {field} حقولًا تُسمّى أثناء التشغيل + Some names are built as it runs تُبنى بعض الأسماء أثناء التشغيل Back to Prepare a form العودة إلى تجهيز نموذج No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. لا يحسب أي حقل في هذا النموذج قيمته بعد. امنح حقلًا عملية حساب في علامة التبويب «الحساب» ضمن خصائصه، وسيظهر هنا. {names} and {last} {names} و{last} @@ -2428,6 +2432,11 @@ animation effects Off معطّلة @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، الذي يقرؤه.|{field} يُحسب قبل {fields}، اللذين يقرؤهما.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} من الحقول الأخرى تُحسب مبكرًا جدًا.|حقل آخر ({n}) يُحسب مبكرًا جدًا.|حقلان آخران ({n}) يُحسبان مبكرًا جدًا.|{n} حقول أخرى تُحسب مبكرًا جدًا.|{n} حقلًا آخر يُحسب مبكرًا جدًا.|{n} من الحقول الأخرى تُحسب مبكرًا جدًا. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجود في الترتيب المحفوظ في PDF، لذا قد لا تحسبه عارضات PDF الأخرى.|{fields} غير موجودين في الترتيب المحفوظ في PDF، لذا قد لا تحسبهما عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} من الصيغ تقرأ حقولًا غير موجودة في النموذج|صيغة واحدة ({n}) تقرأ حقلًا غير موجود في النموذج|صيغتان ({n}) تقرآن حقولًا غير موجودة في النموذج|{n} صيغ تقرأ حقولًا غير موجودة في النموذج|{n} صيغة تقرأ حقولًا غير موجودة في النموذج|{n} من الصيغ تقرأ حقولًا غير موجودة في النموذج +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقل {fields}، وهو غير موجود في النموذج.|يقرأ {field} الحقلين {fields}، وهما غير موجودين في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} من الصيغ الأخرى تقرأ حقولًا مفقودة.|صيغة أخرى ({n}) تقرأ حقلًا مفقودًا.|صيغتان أخريان ({n}) تقرآن حقولًا مفقودة.|{n} صيغ أخرى تقرأ حقولًا مفقودة.|{n} صيغة أخرى تقرأ حقولًا مفقودة.|{n} من الصيغ الأخرى تقرأ حقولًا مفقودة. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات. +@plural {fields} is missing|{fields} are missing {fields} مفقودة|{fields} مفقود|{fields} مفقودان|{fields} مفقودة|{fields} مفقودة|{fields} مفقودة Scan مسح ضوئي Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق. Choose photo… اختيار صورة… diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 2ef342e51b..a41f559a4f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -204,6 +204,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Липсва в реда на изчисляване, записан в PDF файла: други програми за PDF може да не го изчисляват. Reads {name}, which the form doesn't have, so it gets no value there. Чете {name}, което го няма във формуляра, затова не получава стойност оттам. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Скриптът му чете и полета, чиито имена съставя по време на изпълнение, затова тези четения не могат да бъдат проверени. + Missing field Липсващо поле + Unchecked reads Непроверени четения + {field} reads fields named as it runs {field} чете полета, именувани по време на изпълнение + Some names are built as it runs Някои имена се съставят по време на изпълнение Back to Prepare a form Назад към „Подготви формуляр“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Все още нито едно поле в този формуляр не изчислява стойността си. Задайте изчисление на поле в раздела „Изчисляване“ на свойствата му и то ще се покаже тук. {names} and {last} {names} и {last} @@ -2370,6 +2374,11 @@ animation effects Off Изключени @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} се изчислява преди {fields}, което чете.|{field} се изчислява преди {fields}, които чете. @plural {n} more field calculates too early.|{n} more fields calculate too early. Още {n} поле се изчислява твърде рано.|Още {n} полета се изчисляват твърде рано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} липсва в реда, запазен в PDF файла, затова други програми за PDF може да не го изчислят.|{fields} липсват в реда, запазен в PDF файла, затова други програми за PDF може да не ги изчислят. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула чете поле, което го няма във формуляра|{n} формули четат полета, които ги няма във формуляра +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} чете {fields}, което го няма във формуляра.|{field} чете {fields}, които ги няма във формуляра. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Още {n} формула чете липсващо поле.|Още {n} формули четат липсващи полета. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} съставя някои имена на полета по време на изпълнение, затова тези четения не се проверяват.|{fields} съставят някои имена на полета по време на изпълнение, затова тези четения не се проверяват. +@plural {fields} is missing|{fields} are missing {fields} липсва|{fields} липсват Scan Сканиране Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията. Choose photo… Избор на снимка… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index dda1208dad..2492fbdf96 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2722,6 +2722,10 @@ theme Appearance Aspecte Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs Back to Prepare a form Back to Prepare a form No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. {names} and {last} {names} and {last} @@ -2750,6 +2754,11 @@ theme Appearance Aspecte @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. +@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bulleted list Bulleted list diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3c38493305..9bce9d152e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -627,6 +627,10 @@ key Space Mezerník Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Chybí v pořadí výpočtů uloženém v PDF: jiné prohlížeče PDF ho možná nepočítají. Reads {name}, which the form doesn't have, so it gets no value there. Čte {name}, které formulář nemá, takže odtud nedostane žádnou hodnotu. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jeho skript čte i pole, jejichž názvy sestavuje za běhu, takže tato čtení nelze zkontrolovat. + Missing field Chybí pole + Unchecked reads Neověřená čtení + {field} reads fields named as it runs {field} čte pole pojmenovaná za běhu + Some names are built as it runs Některé názvy se sestavují za běhu Back to Prepare a form Zpět na Připravit formulář No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Žádné pole tohoto formuláře zatím nepočítá svou hodnotu. Zadejte poli výpočet na kartě Výpočet v jeho vlastnostech a zobrazí se zde. {names} and {last} {names} a {last} @@ -2481,6 +2485,11 @@ animation effects Off Vypnuto @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se počítá před {fields}, které čte.|{field} se počítá před {fields}, která čte.|{field} se počítá před {fields}, která čte. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ještě {n} pole se počítá příliš brzy.|Ještě {n} pole se počítají příliš brzy.|Ještě {n} polí se počítá příliš brzy. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} chybí v pořadí uloženém v PDF, takže ho jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} vzorec čte pole, které formulář nemá|{n} vzorce čtou pole, která formulář nemá|{n} vzorců čte pole, která formulář nemá +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} čte {fields}, které formulář nemá.|{field} čte {fields}, která formulář nemá.|{field} čte {fields}, která formulář nemá. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ještě {n} vzorec čte chybějící pole.|Ještě {n} vzorce čtou chybějící pole.|Ještě {n} vzorců čte chybějící pole. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} sestavuje některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena. +@plural {fields} is missing|{fields} are missing {fields} chybí|{fields} chybí|{fields} chybí Scan Skenovat Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír. Choose photo… Vybrat fotografii… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8d605a024b..b65e5870f7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -516,6 +516,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Fehlt in der im PDF gespeicherten Berechnungsreihenfolge: Andere PDF-Viewer berechnen es womöglich nicht. Reads {name}, which the form doesn't have, so it gets no value there. Liest {name}, das es im Formular nicht gibt, und erhält dort deshalb keinen Wert. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sein Skript liest auch Felder, deren Namen es erst beim Ausführen bildet. Diese Lesezugriffe lassen sich nicht prüfen. + Missing field Feld fehlt + Unchecked reads Ungeprüfte Lesezugriffe + {field} reads fields named as it runs {field} liest Felder, deren Namen erst beim Ausführen entstehen + Some names are built as it runs Manche Namen entstehen erst beim Ausführen Back to Prepare a form Zurück zu „Formular vorbereiten“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Noch berechnet kein Feld dieses Formulars seinen Wert. Gib einem Feld auf dem Tab „Berechnen“ seiner Eigenschaften eine Berechnung, dann erscheint es hier. {names} and {last} {names} und {last} @@ -2408,6 +2412,11 @@ animation effects Off Aus @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} wird vor {fields} berechnet, das es liest.|{field} wird vor {fields} berechnet, die es liest. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} weiteres Feld wird zu früh berechnet.|{n} weitere Felder werden zu früh berechnet. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} fehlt in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer es womöglich nicht.|{fields} fehlen in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer sie womöglich nicht. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} Formel liest ein Feld, das es im Formular nicht gibt|{n} Formeln lesen Felder, die es im Formular nicht gibt +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} liest {fields}, das es im Formular nicht gibt.|{field} liest {fields}, die es im Formular nicht gibt. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} weitere Formel liest ein fehlendes Feld.|{n} weitere Formeln lesen fehlende Felder. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} bildet manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft.|{fields} bilden manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft. +@plural {fields} is missing|{fields} are missing {fields} fehlt|{fields} fehlen Scan Scannen Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier. Choose photo… Foto auswählen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3efb631ae1..b292363bf5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1854,6 +1854,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Λείπει από τη σειρά υπολογισμού που είναι αποθηκευμένη στο PDF: άλλα προγράμματα προβολής PDF ίσως δεν το υπολογίζουν. Reads {name}, which the form doesn't have, so it gets no value there. Διαβάζει το {name}, που δεν υπάρχει στη φόρμα, οπότε δεν παίρνει τιμή από εκεί. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Η δέσμη ενεργειών του διαβάζει επίσης πεδία των οποίων τα ονόματα σχηματίζει κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν μπορούν να ελεγχθούν. + Missing field Λείπει πεδίο + Unchecked reads Μη ελεγμένες αναγνώσεις + {field} reads fields named as it runs Το {field} διαβάζει πεδία που ονομάζονται κατά την εκτέλεση + Some names are built as it runs Ορισμένα ονόματα σχηματίζονται κατά την εκτέλεση Back to Prepare a form Επιστροφή στην Προετοιμασία φόρμας No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Κανένα πεδίο αυτής της φόρμας δεν υπολογίζει ακόμη την τιμή του. Δώστε σε ένα πεδίο υπολογισμό στην καρτέλα Υπολογισμός των ιδιοτήτων του και θα εμφανιστεί εδώ. {names} and {last} {names} και {last} @@ -2404,6 +2408,11 @@ animation effects Off Ανενεργά @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} υπολογίζεται πριν από {fields}, που διαβάζει.|{field} υπολογίζεται πριν από {fields}, που διαβάζει. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ακόμη {n} πεδίο υπολογίζεται πολύ νωρίς.|Ακόμη {n} πεδία υπολογίζονται πολύ νωρίς. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} λείπει από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν το υπολογίσουν.|{fields} λείπουν από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν τα υπολογίσουν. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} τύπος διαβάζει πεδίο που δεν υπάρχει στη φόρμα|{n} τύποι διαβάζουν πεδία που δεν υπάρχουν στη φόρμα +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. Το {field} διαβάζει το {fields}, που δεν υπάρχει στη φόρμα.|Το {field} διαβάζει τα {fields}, που δεν υπάρχουν στη φόρμα. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ακόμη {n} τύπος διαβάζει πεδίο που λείπει.|Ακόμη {n} τύποι διαβάζουν πεδία που λείπουν. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. Το {fields} σχηματίζει ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται.|Τα {fields} σχηματίζουν ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται. +@plural {fields} is missing|{fields} are missing Το {fields} λείπει|Τα {fields} λείπουν @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list} Scan Σάρωση Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fb17254189..a39fe15a18 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -539,6 +539,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Falta en el orden de cálculo guardado en el PDF: otros visores de PDF podrían no calcularlo. Reads {name}, which the form doesn't have, so it gets no value there. Lee {name}, que no existe en el formulario, así que no obtiene ningún valor de ahí. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Su script también lee campos cuyos nombres construye mientras se ejecuta, así que esas lecturas no se pueden comprobar. + Missing field Falta un campo + Unchecked reads Lecturas sin comprobar + {field} reads fields named as it runs {field} lee campos nombrados mientras se ejecuta + Some names are built as it runs Algunos nombres se construyen al ejecutarse Back to Prepare a form Volver a Preparar un formulario No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ningún campo de este formulario calcula todavía su valor. Dale a un campo un cálculo en la pestaña Calcular de sus propiedades y aparecerá aquí. {names} and {last} {names} y {last} @@ -2543,6 +2547,11 @@ animation effects Off Desactivados @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calcula antes que {fields}, que lee.|{field} se calcula antes que {fields}, que lee. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} campo más se calcula demasiado pronto.|{n} campos más se calculan demasiado pronto. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} falta en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlo.|{fields} faltan en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlos. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lee un campo que no existe en el formulario|{n} fórmulas leen campos que no existen en el formulario +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lee {fields}, que no existe en el formulario.|{field} lee {fields}, que no existen en el formulario. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} fórmula más lee un campo que falta.|{n} fórmulas más leen campos que faltan. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construye algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban.|{fields} construyen algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban. +@plural {fields} is missing|{fields} are missing Falta {fields}|Faltan {fields} Scan Escanear Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma en una hoja de papel blanco y hazle una foto con luz intensa y uniforme (usa el flash). Elige la foto o suéltala aquí: PdfCraft encuentra la firma y elimina el papel. Choose photo… Elegir foto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f35cb08d5a..1d4e6c7d79 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2786,6 +2786,15 @@ theme Appearance Teema # Untranslated: English text until a translator fills these in. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. +@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e83249e394..34c8ce8d2a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -516,6 +516,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Absent de l'ordre de calcul enregistré dans le PDF : d'autres lecteurs PDF risquent de ne pas le calculer. Reads {name}, which the form doesn't have, so it gets no value there. Lit {name}, qui n'existe pas dans le formulaire : aucune valeur n'en provient. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Son script lit aussi des champs dont il construit les noms à l'exécution : ces lectures ne peuvent pas être vérifiées. + Missing field Champ manquant + Unchecked reads Lectures non vérifiées + {field} reads fields named as it runs {field} lit des champs nommés à l'exécution + Some names are built as it runs Certains noms sont construits à l'exécution Back to Prepare a form Retour à Préparer un formulaire No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Aucun champ de ce formulaire ne calcule encore sa valeur. Donnez un calcul à un champ dans l'onglet Calculer de ses propriétés, et il apparaîtra ici. {names} and {last} {names} et {last} @@ -2407,6 +2411,11 @@ animation effects Off Désactivés @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} est calculé avant {fields}, qu'il lit.|{field} est calculé avant {fields}, qu'il lit. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} autre champ est calculé trop tôt.|{n} autres champs sont calculés trop tôt. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manque dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas le calculer.|{fields} manquent dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas les calculer. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formule lit un champ absent du formulaire|{n} formules lisent des champs absents du formulaire +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lit {fields}, absent du formulaire.|{field} lit {fields}, absents du formulaire. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} autre formule lit un champ manquant.|{n} autres formules lisent des champs manquants. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construit certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées.|{fields} construisent certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées. +@plural {fields} is missing|{fields} are missing {fields} est absent|{fields} sont absents Scan Numériser Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier. Choose photo… Choisir une photo… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 354b58ee89..d11767a516 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2723,6 +2723,10 @@ theme Appearance स्वरूप Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index f64c76d51a..dc08ffae08 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -514,6 +514,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Hiányzik a PDF-ben mentett számítási sorrendből: más PDF-megjelenítők talán nem számítják ki. Reads {name}, which the form doesn't have, so it gets no value there. Ezt olvassa: {name}, amely nincs az űrlapon, így onnan nem kap értéket. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. A szkriptje olyan mezőket is olvas, amelyek nevét futás közben állítja össze, így ezek az olvasások nem ellenőrizhetők. + Missing field Hiányzó mező + Unchecked reads Ellenőrizetlen olvasások + {field} reads fields named as it runs {field} futás közben elnevezett mezőket olvas + Some names are built as it runs Néhány név futás közben áll össze Back to Prepare a form Vissza az Űrlap előkészítéséhez No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ennek az űrlapnak még egyik mezője sem számítja ki az értékét. Adj egy mezőnek számítást a tulajdonságai Számítás lapján, és itt megjelenik. {names} and {last} {names} és {last} @@ -2406,6 +2410,11 @@ animation effects Off Ki @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} előbb számítódik, mint {fields}, amelyet olvas.|{field} előbb számítódik, mint {fields}, amelyeket olvas. @plural {n} more field calculates too early.|{n} more fields calculate too early. További {n} mező túl korán számítódik.|További {n} mező túl korán számítódik. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} hiányzik a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki.|{fields} hiányoznak a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki őket. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} képlet olyan mezőt olvas, amely nincs az űrlapon|{n} képlet olyan mezőket olvas, amelyek nincsenek az űrlapon +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ezt olvassa: {fields}, amely nincs az űrlapon.|{field} ezeket olvassa: {fields}, amelyek nincsenek az űrlapon. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. További {n} képlet hiányzó mezőt olvas.|További {n} képlet hiányzó mezőket olvas. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} futás közben állít össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve.|{fields} futás közben állítanak össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve. +@plural {fields} is missing|{fields} are missing {fields} hiányzik|{fields} hiányoznak Scan Beolvasás Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt. Choose photo… Fénykép kiválasztása… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0f62baf71f..568450aaa4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2724,6 +2724,10 @@ theme Appearance Tampilan Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8c8208e706..cc6651c482 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -540,6 +540,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manca nell'ordine di calcolo salvato nel PDF: altri visualizzatori PDF potrebbero non calcolarlo. Reads {name}, which the form doesn't have, so it gets no value there. Legge {name}, che non esiste nel modulo, quindi da lì non riceve alcun valore. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Il suo script legge anche campi i cui nomi costruisce durante l'esecuzione, quindi queste letture non possono essere verificate. + Missing field Campo mancante + Unchecked reads Letture non verificate + {field} reads fields named as it runs {field} legge campi nominati durante l'esecuzione + Some names are built as it runs Alcuni nomi vengono costruiti durante l'esecuzione Back to Prepare a form Torna a Prepara un modulo No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nessun campo di questo modulo calcola ancora il proprio valore. Assegna un calcolo a un campo nella scheda Calcolo delle sue proprietà e comparirà qui. {names} and {last} {names} e {last} @@ -2416,6 +2420,11 @@ animation effects Off Disattivati @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} viene calcolato prima di {fields}, che legge.|{field} viene calcolato prima di {fields}, che legge. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ancora {n} campo viene calcolato troppo presto.|Altri {n} campi vengono calcolati troppo presto. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manca nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarlo.|{fields} mancano nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarli. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula legge un campo che non esiste nel modulo|{n} formule leggono campi che non esistono nel modulo +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} legge {fields}, che non esiste nel modulo.|{field} legge {fields}, che non esistono nel modulo. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ancora {n} formula legge un campo mancante.|Altre {n} formule leggono campi mancanti. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} costruisce alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate.|{fields} costruiscono alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate. +@plural {fields} is missing|{fields} are missing {fields} manca|{fields} mancano Scan Scansiona Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta. Choose photo… Scegli foto… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a19ef110b..e135e5e1d2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -543,6 +543,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF に保存された計算順序にありません。ほかの PDF ビューアーでは計算されない可能性があります。 Reads {name}, which the form doesn't have, so it gets no value there. フォームにない {name} を読み取るため、そこからは値を得られません。 Its script also reads fields whose names it builds as it runs, so those reads can't be checked. スクリプトが実行中に名前を組み立てるフィールドも読み取るため、それらの読み取りは確認できません。 + Missing field フィールドがない + Unchecked reads 未確認の読み取り + {field} reads fields named as it runs {field} は実行中に名前が決まるフィールドを読み取ります + Some names are built as it runs 一部の名前は実行中に組み立てられます Back to Prepare a form 「フォームを作成」に戻る No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. このフォームには値を計算するフィールドがまだありません。フィールドのプロパティの[計算]タブで計算を設定すると、ここに表示されます。 {names} and {last} {names}、{last} @@ -2407,6 +2411,11 @@ animation effects Off オフ @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} は、読み取る {fields} より先に計算されます。 @plural {n} more field calculates too early.|{n} more fields calculate too early. さらに {n} 個のフィールドが早すぎます。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} は PDF に保存された順序にないため、他の PDF ビューアーでは計算されない場合があります。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個の数式がフォームにないフィールドを読み取ります +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} はフォームにない {fields} を読み取ります。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. ほかに {n} 個の数式が存在しないフィールドを読み取ります。 +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} は一部のフィールド名を実行中に組み立てるため、それらの読み取りは確認されません。 +@plural {fields} is missing|{fields} are missing {fields} がありません Scan スキャン Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。 Choose photo… 写真を選択… diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index ff5bcd317b..6fd9157694 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -503,6 +503,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF ішінде сақталған есептеу ретінде жоқ: басқа PDF қарау құралдары оны есептемеуі мүмкін. Reads {name}, which the form doesn't have, so it gets no value there. {name} өрісін оқиды, бірақ ол пішінде жоқ, сондықтан одан мән алмайды. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Оның скрипті атауларын орындалу кезінде құрастыратын өрістерді де оқиды, сондықтан бұл оқуларды тексеру мүмкін емес. + Missing field Өріс жоқ + Unchecked reads Тексерілмеген оқулар + {field} reads fields named as it runs {field} орындалу кезінде аталатын өрістерді оқиды + Some names are built as it runs Кейбір атаулар орындалу кезінде құрастырылады Back to Prepare a form Форманы дайындауға оралу No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Бұл формада мәнін есептейтін өріс әлі жоқ. Өріс сипаттарының «Есептеу» қойындысында оған есептеу беріңіз, сонда ол осында көрсетіледі. {names} and {last} {names} және {last} @@ -2394,6 +2398,11 @@ animation effects Off Өшірулі @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} өзі оқитын {fields} өрісінен бұрын есептеледі.|{field} өзі оқитын {fields} өрістерінен бұрын есептеледі. @plural {n} more field calculates too early.|{n} more fields calculate too early. Тағы {n} өріс тым ерте есептеледі.|Тағы {n} өріс тым ерте есептеледі. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оны есептемеуі мүмкін.|{fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оларды есептемеуі мүмкін. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула пішінде жоқ өрісті оқиды|{n} формула пішінде жоқ өрістерді оқиды +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} пішінде жоқ {fields} өрісін оқиды.|{field} пішінде жоқ {fields} өрістерін оқиды. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Тағы {n} формула жоқ өрісті оқиды.|Тағы {n} формула жоқ өрістерді оқиды. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді.|{fields} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді. +@plural {fields} is missing|{fields} are missing {fields} жоқ|{fields} жоқ @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list} Scan Сканерлеу Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 139c016b5f..742cf4b3cf 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2723,6 +2723,10 @@ theme Appearance 모양 Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index e19abaf203..57a83db826 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -703,6 +703,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Trūksta PDF faile įrašytoje skaičiavimo tvarkoje: kitos PDF peržiūros programos gali jo neskaičiuoti. Reads {name}, which the form doesn't have, so it gets no value there. Skaito {name}, kurio formoje nėra, todėl iš ten negauna jokios reikšmės. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jo scenarijus taip pat skaito laukus, kurių pavadinimus sudaro vykdymo metu, todėl šių skaitymų patikrinti negalima. + Missing field Trūksta lauko + Unchecked reads Nepatikrinti skaitymai + {field} reads fields named as it runs {field} skaito laukus, pavadinamus vykdymo metu + Some names are built as it runs Kai kurie pavadinimai sudaromi vykdymo metu Back to Prepare a form Grįžti į „Rengti formą“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nė vienas šios formos laukas dar neskaičiuoja savo reikšmės. Nustatykite laukui skaičiavimą jo savybių kortelėje „Skaičiavimas“, ir jis bus rodomas čia. {names} and {last} {names} ir {last} @@ -2670,6 +2674,11 @@ certificate SHA-256 digest SHA-256 santrauka @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} apskaičiuojamas anksčiau už {fields}, kurį skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito. @plural {n} more field calculates too early.|{n} more fields calculate too early. Dar {n} laukas apskaičiuojamas per anksti.|Dar {n} laukai apskaičiuojami per anksti.|Dar {n} laukų apskaičiuojama per anksti. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jo neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulė skaito lauką, kurio formoje nėra|{n} formulės skaito laukus, kurių formoje nėra|{n} formulių skaito laukus, kurių formoje nėra +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} skaito {fields}, kurio formoje nėra.|{field} skaito {fields}, kurių formoje nėra.|{field} skaito {fields}, kurių formoje nėra. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Dar {n} formulė skaito trūkstamą lauką.|Dar {n} formulės skaito trūkstamus laukus.|Dar {n} formulių skaito trūkstamus laukus. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami. +@plural {fields} is missing|{fields} are missing Trūksta {fields}|Trūksta {fields}|Trūksta {fields} key Ctrl Ctrl key Shift Shift key Delete Delete diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index aa3444289f..fa5c1b550c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -538,6 +538,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Brakuje go w kolejności obliczeń zapisanej w pliku PDF: inne przeglądarki PDF mogą go nie obliczać. Reads {name}, which the form doesn't have, so it gets no value there. Odczytuje {name}, którego nie ma w formularzu, więc nie dostaje stamtąd żadnej wartości. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jego skrypt odczytuje też pola, których nazwy tworzy w trakcie działania, więc tych odczytów nie da się sprawdzić. + Missing field Brak pola + Unchecked reads Niesprawdzone odczyty + {field} reads fields named as it runs {field} odczytuje pola nazywane w trakcie działania + Some names are built as it runs Niektóre nazwy powstają w trakcie działania Back to Prepare a form Wróć do „Przygotuj formularz” No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Żadne pole tego formularza nie oblicza jeszcze swojej wartości. Nadaj polu obliczenie na karcie Obliczenia w jego właściwościach, a pojawi się tutaj. {names} and {last} {names} i {last} @@ -2375,6 +2379,11 @@ animation effects Off Wyłączone @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje. @plural {n} more field calculates too early.|{n} more fields calculate too early. Jeszcze {n} pole jest obliczane za wcześnie.|Jeszcze {n} pola są obliczane za wcześnie.|Jeszcze {n} pól jest obliczanych za wcześnie. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą go nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formuła odczytuje pole, którego nie ma w formularzu|{n} formuły odczytują pola, których nie ma w formularzu|{n} formuł odczytuje pola, których nie ma w formularzu +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} odczytuje {fields}, którego nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Jeszcze {n} formuła odczytuje brakujące pole.|Jeszcze {n} formuły odczytują brakujące pola.|Jeszcze {n} formuł odczytuje brakujące pola. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} tworzy część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane. +@plural {fields} is missing|{fields} are missing Brak {fields}|Brak {fields}|Brak {fields} Scan Skanuj Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier. Choose photo… Wybierz zdjęcie… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5ca0e2fb60..dfe0496f90 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -161,6 +161,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Ausente da ordem de cálculo salva no PDF: outros leitores de PDF podem não calculá-lo. Reads {name}, which the form doesn't have, so it gets no value there. Lê {name}, que não existe no formulário, então não recebe nenhum valor dali. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Seu script também lê campos cujos nomes monta durante a execução, então essas leituras não podem ser verificadas. + Missing field Campo ausente + Unchecked reads Leituras não verificadas + {field} reads fields named as it runs {field} lê campos nomeados durante a execução + Some names are built as it runs Alguns nomes são montados durante a execução Back to Prepare a form Voltar para Preparar um formulário No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nenhum campo deste formulário calcula seu valor ainda. Dê um cálculo a um campo na aba Calcular das propriedades dele, e ele aparecerá aqui. {names} and {last} {names} e {last} @@ -2421,6 +2425,11 @@ animation effects Off Desativados @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} é calculado antes de {fields}, que ele lê.|{field} é calculado antes de {fields}, que ele lê. @plural {n} more field calculates too early.|{n} more fields calculate too early. Mais {n} campo é calculado cedo demais.|Mais {n} campos são calculados cedo demais. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} não está na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-lo.|{fields} não estão na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-los. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lê um campo que não existe no formulário|{n} fórmulas leem campos que não existem no formulário +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lê {fields}, que não existe no formulário.|{field} lê {fields}, que não existem no formulário. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Mais {n} fórmula lê um campo ausente.|Mais {n} fórmulas leem campos ausentes. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} monta alguns nomes de campo durante a execução, então essas leituras não são verificadas.|{fields} montam alguns nomes de campo durante a execução, então essas leituras não são verificadas. +@plural {fields} is missing|{fields} are missing {fields} não existe|{fields} não existem Scan Digitalizar Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel. Choose photo… Escolher foto… diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index cf1c74ad4d..413bd022dc 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2725,6 +2725,10 @@ theme Appearance Aspect Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Lipsește din ordinea de calcul salvată în PDF: alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze. Reads {name}, which the form doesn't have, so it gets no value there. Citește {name}, care nu există în formular, deci nu primește nicio valoare de acolo. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Scriptul său citește și câmpuri ale căror nume le construiește în timpul rulării, deci aceste citiri nu pot fi verificate. + Missing field Câmp lipsă + Unchecked reads Citiri neverificate + {field} reads fields named as it runs {field} citește câmpuri numite în timpul rulării + Some names are built as it runs Unele nume sunt construite în timpul rulării Back to Prepare a form Înapoi la Pregătire formular No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Niciun câmp din acest formular nu își calculează încă valoarea. Adăugați un calcul unui câmp în fila Calcul din proprietățile lui și va apărea aici. {names} and {last} {names} și {last} @@ -2752,6 +2756,11 @@ theme Appearance Aspect @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calculează înaintea câmpului {fields}, pe care îl citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește. @plural {n} more field calculates too early.|{n} more fields calculate too early. Încă {n} câmp se calculează prea devreme.|Încă {n} câmpuri se calculează prea devreme.|Încă {n} de câmpuri se calculează prea devreme. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} lipsește din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulă citește un câmp care nu există în formular|{n} formule citesc câmpuri care nu există în formular|{n} de formule citesc câmpuri care nu există în formular +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Încă {n} formulă citește un câmp lipsă.|Încă {n} formule citesc câmpuri lipsă.|Încă {n} de formule citesc câmpuri lipsă. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} construiește unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate. +@plural {fields} is missing|{fields} are missing {fields} lipsește|{fields} lipsesc|{fields} lipsesc Bulleted list Listă cu marcatori Numbered list Listă numerotată Alignment Aliniere diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index acfc72a2a0..be3625bc6c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -536,6 +536,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Отсутствует в порядке вычислений, сохранённом в PDF: другие программы просмотра PDF могут его не вычислять. Reads {name}, which the form doesn't have, so it gets no value there. Читает {name}, которого нет в форме, поэтому не получает оттуда значения. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Его скрипт также читает поля, имена которых составляет во время выполнения, поэтому эти чтения нельзя проверить. + Missing field Нет поля + Unchecked reads Непроверенные чтения + {field} reads fields named as it runs {field} читает поля, имена которых задаются при выполнении + Some names are built as it runs Некоторые имена составляются при выполнении Back to Prepare a form Назад к «Подготовить форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ни одно поле этой формы пока не вычисляет своё значение. Задайте полю вычисление на вкладке «Вычислить» его свойств, и оно появится здесь. {names} and {last} {names} и {last} @@ -2320,6 +2324,11 @@ animation effects Off Выкл @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} вычисляется раньше {fields}, которое оно читает.|{field} вычисляется раньше {fields}, которые оно читает.|{field} вычисляется раньше {fields}, которые оно читает. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ещё {n} поле вычисляется слишком рано.|Ещё {n} поля вычисляются слишком рано.|Ещё {n} полей вычисляются слишком рано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут его не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читает поле, которого нет в форме|{n} формулы читают поля, которых нет в форме|{n} формул читают поля, которых нет в форме +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читает {fields}, которого нет в форме.|{field} читает {fields}, которых нет в форме.|{field} читает {fields}, которых нет в форме. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ещё {n} формула читает отсутствующее поле.|Ещё {n} формулы читают отсутствующие поля.|Ещё {n} формул читают отсутствующие поля. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} составляет некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются. +@plural {fields} is missing|{fields} are missing {fields} отсутствует|{fields} отсутствуют|{fields} отсутствуют Scan Сканировать Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу. Choose photo… Выбрать фото… diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 19ff9c059b..94d7f91b14 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1029,6 +1029,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manjka v vrstnem redu izračunov, shranjenem v PDF-ju: drugi pregledovalniki PDF ga morda ne izračunajo. Reads {name}, which the form doesn't have, so it gets no value there. Bere {name}, ki ga v obrazcu ni, zato od tam ne dobi vrednosti. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Njegov skript bere tudi polja, katerih imena sestavi med izvajanjem, zato teh branj ni mogoče preveriti. + Missing field Manjka polje + Unchecked reads Nepreverjena branja + {field} reads fields named as it runs {field} bere polja, poimenovana med izvajanjem + Some names are built as it runs Nekatera imena se sestavijo med izvajanjem Back to Prepare a form Nazaj na Pripravi obrazec No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nobeno polje tega obrazca še ne izračuna svoje vrednosti. Polju dodelite izračun na zavihku Izračunaj v njegovih lastnostih in prikazalo se bo tukaj. {names} and {last} {names} in {last} @@ -2411,6 +2415,11 @@ animation effects Off Izklopljeni @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se izračuna pred {fields}, ki ga bere.|{field} se izračuna pred {fields}, ki ju bere.|{field} se izračuna pred {fields}, ki jih bere.|{field} se izračuna pred {fields}, ki jih bere. @plural {n} more field calculates too early.|{n} more fields calculate too early. Še {n} polje se izračuna prezgodaj.|Še {n} polji se izračunata prezgodaj.|Še {n} polja se izračunajo prezgodaj.|Še {n} polj se izračuna prezgodaj. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manjka v vrstnem redu, shranjenem v PDF-ju, zato ga drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkata v vrstnem redu, shranjenem v PDF-ju, zato ju drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula bere polje, ki ga v obrazcu ni|{n} formuli bereta polja, ki ju v obrazcu ni|{n} formule berejo polja, ki jih v obrazcu ni|{n} formul bere polja, ki jih v obrazcu ni +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} bere {fields}, ki ga v obrazcu ni.|{field} bere {fields}, ki ju v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Še {n} formula bere manjkajoče polje.|Še {n} formuli bereta manjkajoča polja.|Še {n} formule berejo manjkajoča polja.|Še {n} formul bere manjkajoča polja. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} nekatera imena polj sestavi med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavita med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena. +@plural {fields} is missing|{fields} are missing {fields} manjka|{fields} manjkata|{fields} manjkajo|{fields} manjka @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list} Scan Skeniraj Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir. diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index ed552c8005..b6fb44e607 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2411,6 +2411,10 @@ animation effects Off Joaktiv Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs Mobile Mobile Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index e058226539..4c4106fac4 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -520,6 +520,10 @@ comment menu Edit సవరించు Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDFలో సేవ్ చేసిన గణన క్రమంలో లేదు: ఇతర PDF వీక్షకులు దీన్ని లెక్కించకపోవచ్చు. Reads {name}, which the form doesn't have, so it gets no value there. {name}ను చదువుతుంది, కానీ అది ఫారమ్‌లో లేదు, కాబట్టి దాని నుండి విలువ రాదు. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. దీని స్క్రిప్ట్ నడుస్తున్నప్పుడు పేర్లను కూర్చే ఫీల్డ్‌లను కూడా చదువుతుంది, కాబట్టి ఆ చదువులను తనిఖీ చేయలేము. + Missing field ఫీల్డ్ లేదు + Unchecked reads తనిఖీ చేయని చదువులు + {field} reads fields named as it runs {field} నడుస్తున్నప్పుడు పేరు పెట్టే ఫీల్డ్‌లను చదువుతుంది + Some names are built as it runs కొన్ని పేర్లు నడుస్తున్నప్పుడు కూర్చబడతాయి Back to Prepare a form ‘ఫారాన్ని సిద్ధం చేయి’కి తిరిగి వెళ్లు No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. ఈ ఫారంలో ఏ ఫీల్డ్ ఇంకా తన విలువను గణించదు. ఫీల్డ్ లక్షణాలలోని గణన ట్యాబ్‌లో దానికి గణన ఇవ్వండి, అది ఇక్కడ కనిపిస్తుంది. {names} and {last} {names} మరియు {last} @@ -2308,6 +2312,11 @@ animation effects Off ఆఫ్ @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది.|{field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది. @plural {n} more field calculates too early.|{n} more fields calculate too early. మరో {n} ఫీల్డ్ చాలా ముందుగా గణించబడుతుంది.|మరో {n} ఫీల్డ్‌లు చాలా ముందుగా గణించబడతాయి. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDFలో సేవ్ చేసిన క్రమంలో లేదు, కాబట్టి ఇతర PDF వ్యూయర్‌లు దాన్ని గణించకపోవచ్చు.|{fields} PDFలో సేవ్ చేసిన క్రమంలో లేవు, కాబట్టి ఇతర PDF వ్యూయర్‌లు వాటిని గణించకపోవచ్చు. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} సూత్రం ఫారమ్‌లో లేని ఫీల్డ్‌ను చదువుతుంది|{n} సూత్రాలు ఫారమ్‌లో లేని ఫీల్డ్‌లను చదువుతాయి +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది.|{field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. మరో {n} సూత్రం లేని ఫీల్డ్‌ను చదువుతుంది.|మరో {n} సూత్రాలు లేని ఫీల్డ్‌లను చదువుతాయి. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతుంది, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు.|{fields} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతాయి, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు. +@plural {fields} is missing|{fields} are missing {fields} లేదు|{fields} లేవు Scan స్కాన్ Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది. Choose photo… ఫోటో ఎంచుకోండి… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0be6a69520..1c6f21f1eb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -501,6 +501,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF'te kayıtlı hesaplama sırasında yok: diğer PDF görüntüleyicileri bunu hesaplamayabilir. Reads {name}, which the form doesn't have, so it gets no value there. Formda olmayan {name} alanını okur, bu yüzden oradan değer alamaz. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Komut dosyası, adlarını çalışırken oluşturduğu alanları da okur; bu okumalar denetlenemez. + Missing field Eksik alan + Unchecked reads Denetlenmeyen okumalar + {field} reads fields named as it runs {field}, çalışırken adlandırılan alanları okuyor + Some names are built as it runs Bazı adlar çalışırken oluşturuluyor Back to Prepare a form Form hazırla'ya geri dön No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Bu formdaki hiçbir alan henüz değerini hesaplamıyor. Bir alana özelliklerinin Hesapla sekmesinde bir hesaplama verin, burada görünür. {names} and {last} {names} ve {last} @@ -2392,6 +2396,11 @@ animation effects Off Kapalı @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} önce hesaplanıyor; okuduğu {fields} daha sonra hesaplanıyor. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} alan daha çok erken hesaplanıyor. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields}, PDF'te kayıtlı sırada yok; bu yüzden diğer PDF görüntüleyicileri hesaplamayabilir. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formül formda olmayan alanları okuyor +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field}, formda olmayan {fields} alanını okuyor. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} formül daha eksik alanları okuyor. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields}, bazı alan adlarını çalışırken oluşturuyor; bu okumalar denetlenmiyor. +@plural {fields} is missing|{fields} are missing {fields} eksik @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list} Scan Tara Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5d45a3f4b6..c1b08a7fc5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -541,6 +541,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Відсутнє в порядку обчислень, збереженому в PDF: інші переглядачі PDF можуть його не обчислювати. Reads {name}, which the form doesn't have, so it gets no value there. Читає {name}, якого немає у формі, тому не отримує звідти значення. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Його сценарій також читає поля, імена яких складає під час виконання, тому ці читання неможливо перевірити. + Missing field Немає поля + Unchecked reads Неперевірені читання + {field} reads fields named as it runs {field} читає поля, імена яких задаються під час виконання + Some names are built as it runs Деякі імена складаються під час виконання Back to Prepare a form Назад до «Підготувати форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Жодне поле цієї форми поки не обчислює своє значення. Задайте полю обчислення на вкладці «Обчислення» його властивостей, і воно з’явиться тут. {names} and {last} {names} і {last} @@ -2429,6 +2433,11 @@ animation effects Off Вимкнено @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} обчислюється раніше за {fields}, яке воно читає.|{field} обчислюється раніше за {fields}, які воно читає.|{field} обчислюється раніше за {fields}, які воно читає. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ще {n} поле обчислюється зарано.|Ще {n} поля обчислюються зарано.|Ще {n} полів обчислюються зарано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть його не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читає поле, якого немає у формі|{n} формули читають поля, яких немає у формі|{n} формул читають поля, яких немає у формі +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читає {fields}, якого немає у формі.|{field} читає {fields}, яких немає у формі.|{field} читає {fields}, яких немає у формі. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ще {n} формула читає відсутнє поле.|Ще {n} формули читають відсутні поля.|Ще {n} формул читають відсутні поля. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} складає деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються. +@plural {fields} is missing|{fields} are missing Немає {fields}|Немає {fields}|Немає {fields} Scan Сканувати Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір. Choose photo… Вибрати фото… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2b51f445a3..ea218959dc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -699,6 +699,10 @@ theme Appearance Giao diện Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Không có trong thứ tự tính toán lưu trong PDF: các trình xem PDF khác có thể không tính trường này. Reads {name}, which the form doesn't have, so it gets no value there. Đọc {name}, nhưng biểu mẫu không có trường này, nên không nhận được giá trị nào từ đó. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Tập lệnh của nó còn đọc các trường có tên được tạo ra khi chạy, nên không thể kiểm tra các lần đọc đó. + Missing field Thiếu trường + Unchecked reads Lần đọc chưa kiểm tra + {field} reads fields named as it runs {field} đọc các trường được đặt tên khi chạy + Some names are built as it runs Một số tên được tạo ra khi chạy Back to Prepare a form Quay lại Chuẩn bị biểu mẫu No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Chưa có trường nào trong biểu mẫu này tự tính giá trị. Hãy đặt phép tính cho một trường ở thẻ Tính toán trong thuộc tính của nó, trường đó sẽ hiện ở đây. {names} and {last} {names} và {last} @@ -2658,6 +2662,11 @@ certificate SHA-256 digest Giá trị băm SHA-256 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} được tính trước {fields} mà nó đọc. @plural {n} more field calculates too early.|{n} more fields calculate too early. Còn {n} trường khác được tính quá sớm. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} không có trong thứ tự lưu trong PDF, nên các trình xem PDF khác có thể không tính. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} công thức đọc trường không có trong biểu mẫu +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} đọc {fields}, nhưng biểu mẫu không có. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Còn {n} công thức khác đọc trường bị thiếu. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} tạo một số tên trường khi chạy, nên các lần đọc đó không được kiểm tra. +@plural {fields} is missing|{fields} are missing Thiếu {fields} key Ctrl Ctrl key Shift Shift key Delete Delete diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c4aae05c8a..7eaf75315e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -210,6 +210,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中保存的计算顺序缺少此字段:其他 PDF 查看器可能不会计算它。 Reads {name}, which the form doesn't have, so it gets no value there. 读取表单中不存在的 {name},因此从中得不到值。 Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其脚本还会读取运行时才拼出名称的字段,因此无法检查这些读取。 + Missing field 缺少字段 + Unchecked reads 未检查的读取 + {field} reads fields named as it runs {field} 读取运行时才确定名称的字段 + Some names are built as it runs 部分名称在运行时才拼出 Back to Prepare a form 返回准备表单 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表单中还没有计算自身值的字段。在字段属性的“计算”选项卡中为字段设置计算后,它会显示在这里。 {names} and {last} {names}和{last} @@ -2371,6 +2375,11 @@ animation effects Off 关 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它读取的 {fields} 之前计算。 @plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 个字段计算过早。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中保存的顺序里,其他 PDF 查看器可能不会计算。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 个公式读取了表单中不存在的字段 +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 读取表单中不存在的 {fields}。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 个公式读取了缺失的字段。 +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} 在运行时才拼出部分字段名称,因此这些读取不会被检查。 +@plural {fields} is missing|{fields} are missing 缺少 {fields} Scan 扫描 Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。 Choose photo… 选择照片… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 82d6eb6a5d..d977c12950 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -166,6 +166,10 @@ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中儲存的計算順序缺少此欄位:其他 PDF 檢視器可能不會計算它。 Reads {name}, which the form doesn't have, so it gets no value there. 讀取表單中不存在的 {name},因此從中得不到值。 Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其指令碼還會讀取執行時才組出名稱的欄位,因此無法檢查這些讀取。 + Missing field 缺少欄位 + Unchecked reads 未檢查的讀取 + {field} reads fields named as it runs {field} 讀取執行時才確定名稱的欄位 + Some names are built as it runs 部分名稱在執行時才組出 Back to Prepare a form 返回準備表單 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表單中還沒有計算自身值的欄位。在欄位內容的「計算」索引標籤中為欄位設定計算後,它會顯示在這裡。 {names} and {last} {names}和{last} @@ -2270,6 +2274,11 @@ animation effects Off 關 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它讀取的 {fields} 之前計算。 @plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 個欄位計算過早。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中儲存的順序裡,其他 PDF 檢視器可能不會計算。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個公式讀取了表單中不存在的欄位 +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 讀取表單中不存在的 {fields}。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 個公式讀取了缺少的欄位。 +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} 在執行時才組出部分欄位名稱,因此這些讀取不會被檢查。 +@plural {fields} is missing|{fields} are missing 缺少 {fields} Scan 掃描 Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。 Choose photo… 選擇相片… diff --git a/crates/ui-egui/src/tools/calc_order_ui.rs b/crates/ui-egui/src/tools/calc_order_ui.rs index e02983ac5d..bc80983021 100644 --- a/crates/ui-egui/src/tools/calc_order_ui.rs +++ b/crates/ui-egui/src/tools/calc_order_ui.rs @@ -1,8 +1,9 @@ //! Prepare a form ▸ Calculation order, a page of the left panel, and what it shows on the page. //! //! The panel: a card that says whether the order is right and what Fix order does, a card for -//! each loop, and the calculated fields in the order they calculate, each with its formula, the -//! value it shows and what is wrong with its place. A field moves by dragging, with the arrows of +//! each loop, a card for the formulas that read fields the form doesn't have, and the calculated +//! fields in the order they calculate, each with its formula, the value it shows and what is wrong +//! with it. A field moves by dragging, with the arrows of //! the selected field (Acrobat's Set Field Calculation Order has Up and Down), or with Alt+↑/↓. //! Every change is one undoable edit. //! @@ -32,13 +33,15 @@ const MAX_LINKS: usize = 20_000; const MAX_NAMES: usize = 6; /// Loop cards shown at most. const MAX_LOOPS: usize = 5; -/// Fields too early that the card names one by one. -const MAX_LATE: usize = 3; +/// Fields a card names one by one: too early, or reading fields the form doesn't have. +const MAX_LISTED: usize = 3; -/// How a field's place in the order, or a link between two fields, stands. +/// How a field's formula and place in the order, or a link between two fields, stand. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Tone { Ok, + /// Its formula reads a field the form doesn't have. + Missing, /// It calculates before a field it reads, so it uses that field's old value. Late, /// Its formula reads its own result through other formulas. @@ -120,7 +123,9 @@ impl CalcOrderView { } fn tone(issues: &[CalculationIssue]) -> Tone { - if issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. })) { + if issues.iter().any(|i| matches!(i, CalculationIssue::MissingField { .. })) { + Tone::Missing + } else if issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. })) { Tone::Loop } else if issues.iter().any(|i| matches!(i, CalculationIssue::RunsTooEarly { .. })) { Tone::Late @@ -232,7 +237,7 @@ fn loops(calculated: &[CalculatedField]) -> Vec> { } } let mut groups: Vec<(usize, Vec<&str>)> = Vec::new(); - for (k, f) in calculated.iter().enumerate().filter(|(_, f)| tone(&f.issues) == Tone::Loop) { + for (k, f) in calculated.iter().enumerate().filter(|(_, f)| f.issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. }))) { let r = root(&first, k); match groups.iter_mut().find(|(g, _)| *g == r) { Some((_, names)) => names.push(&f.name), @@ -253,7 +258,10 @@ fn names(list: &[&str]) -> String { } /// What the focused field reads and what reads it: "total reads sub and tax · No field reads it". -fn trace(links: &[Link], focus: &str) -> String { +/// `calculated` is the focus in the order, if it calculates: the fields it reads that the form +/// doesn't have, and names its script builds as it runs, are said too. +fn trace(links: &[Link], focus: &str, calculated: Option<&CalculatedField>) -> String { + let lang = crate::i18n::current(); let mut reads: Vec<&str> = Vec::new(); let mut read_by: Vec<&str> = Vec::new(); for l in links { @@ -264,18 +272,32 @@ fn trace(links: &[Link], focus: &str) -> String { read_by.push(&l.to); } } - let reads = if reads.is_empty() { - crate::i18n::fmt(tl!("{field} reads no other field"), &[("field", focus)]) - } else { + let issues = calculated.map(|c| c.issues.as_slice()).unwrap_or_default(); + let missing: Vec<&str> = + issues.iter().filter_map(|i| if let CalculationIssue::MissingField { field } = i { Some(field.as_str()) } else { None }).collect(); + let built = issues.contains(&CalculationIssue::BuiltNames); + reads.extend(&missing); + let mut parts = vec![if !reads.is_empty() { crate::i18n::fmt(tl!("{field} reads {fields}"), &[("field", focus), ("fields", &names(&reads))]) - }; - let read_by = if read_by.is_empty() { + } else if built { + crate::i18n::fmt(tl!("{field} reads fields named as it runs"), &[("field", focus)]) + } else { + crate::i18n::fmt(tl!("{field} reads no other field"), &[("field", focus)]) + }]; + if !missing.is_empty() { + let template = crate::i18n::trn(lang, missing.len() as u64, "{fields} is missing", "{fields} are missing"); + parts.push(crate::i18n::fmt(&template, &[("fields", &names(&missing))])); + } + if built && !reads.is_empty() { + parts.push(tl!("Some names are built as it runs").to_owned()); + } + parts.push(if read_by.is_empty() { tl!("No field reads it").to_owned() } else { - let template = crate::i18n::trn(crate::i18n::current(), read_by.len() as u64, "{fields} reads it", "{fields} read it"); + let template = crate::i18n::trn(lang, read_by.len() as u64, "{fields} reads it", "{fields} read it"); crate::i18n::fmt(&template, &[("fields", &names(&read_by))]) - }; - format!("{reads} · {read_by}") + }); + parts.join(" · ") } /// The colours of the cards and the steps in the list, for the theme. @@ -318,7 +340,7 @@ impl Palette { match tone { Tone::Ok => self.step_ok, Tone::Late => self.step_late, - Tone::Loop => self.step_loop, + Tone::Loop | Tone::Missing => self.step_loop, } } } @@ -386,6 +408,52 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, .collect(); let unlisted: Vec<&str> = order.fields.iter().filter(|f| !f.listed).map(|f| f.name.as_str()).collect(); let loops = loops(&order.fields); + // Each formula that reads fields the form doesn't have, with those fields. + let missing: Vec<(&str, Vec<&str>)> = order + .fields + .iter() + .map(|f| { + let gone = f.issues.iter().filter_map(|i| if let CalculationIssue::MissingField { field } = i { Some(field.as_str()) } else { None }); + (f.name.as_str(), gone.collect::>()) + }) + .filter(|(_, gone)| !gone.is_empty()) + .collect(); + if !missing.is_empty() { + let (fill, edge, title, text) = pal.looped; + card(ui, fill, edge, |ui| { + let heading = crate::i18n::trn( + lang, + missing.len() as u64, + "{n} formula reads a field the form doesn't have", + "{n} formulas read fields the form doesn't have", + ); + card_title(ui, "triangle-alert", &heading, title); + for (field, gone) in missing.iter().take(MAX_LISTED) { + let template = crate::i18n::trn( + lang, + gone.len() as u64, + "{field} reads {fields}, which the form doesn't have.", + "{field} reads {fields}, which the form doesn't have.", + ); + card_text(ui, &crate::i18n::fmt(&template, &[("field", field), ("fields", &names(gone))]), text); + } + if missing.len() > MAX_LISTED { + let more = (missing.len() - MAX_LISTED) as u64; + card_text( + ui, + &crate::i18n::trn(lang, more, "{n} more formula reads a missing field.", "{n} more formulas read missing fields."), + text, + ); + } + if editable + && let Some((first, _)) = missing.first() + && text_button(ui, &crate::i18n::fmt(tl!("Edit {field}'s formula"), &[("field", first)]), t) + { + act = Some(Act::EditFormula((*first).to_owned())); + } + }); + ui.add_space(6.0); + } if order.fixable() { let (fill, edge, title, text) = pal.warn; card(ui, fill, edge, |ui| { @@ -395,7 +463,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, crate::i18n::trn(lang, late.len() as u64, "{n} field shows an old value", "{n} fields show old values") }; card_title(ui, "triangle-alert", &heading, title); - for (field, later) in late.iter().take(MAX_LATE) { + for (field, later) in late.iter().take(MAX_LISTED) { let template = crate::i18n::trn( lang, later.len() as u64, @@ -404,8 +472,8 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, ); card_text(ui, &crate::i18n::fmt(&template, &[("field", field), ("fields", &names(later))]), text); } - if late.len() > MAX_LATE { - let more = (late.len() - MAX_LATE) as u64; + if late.len() > MAX_LISTED { + let more = (late.len() - MAX_LISTED) as u64; card_text(ui, &crate::i18n::trn(lang, more, "{n} more field calculates too early.", "{n} more fields calculate too early."), text); } if !unlisted.is_empty() { @@ -435,7 +503,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, ui.add(egui::Label::new(egui::RichText::new(tl!("Moves only what must move")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); }); }); - } else { + } else if missing.is_empty() || undo { let (fill, edge, colour) = pal.good; card(ui, fill, edge, |ui| { let said = if loops.is_empty() { @@ -467,6 +535,19 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, } }); } + let built: Vec<&str> = order.fields.iter().filter(|f| f.issues.contains(&CalculationIssue::BuiltNames)).map(|f| f.name.as_str()).collect(); + if !built.is_empty() { + ui.add_space(6.0); + card(ui, t.card, t.border, |ui| { + let template = crate::i18n::trn( + lang, + built.len() as u64, + "{fields} builds some field names as it runs, so those reads aren't checked.", + "{fields} build some field names as they run, so those reads aren't checked.", + ); + card_text(ui, &crate::i18n::fmt(&template, &[("fields", &names(&built))]), t.text_muted); + }); + } act } @@ -598,9 +679,11 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let text_x = body.left() + 38.0; let value_end = arrows_x - 4.0; let caption = match row.tone { + Tone::Missing => Some((tl!("Missing field"), pal.step_loop.1)), Tone::Loop => Some((tl!("Loop"), pal.step_loop.1)), Tone::Late => Some((tl!("Too early"), pal.step_late.1)), Tone::Ok if !f.listed => Some((tl!("Not in order"), pal.step_late.1)), + Tone::Ok if f.issues.contains(&CalculationIssue::BuiltNames) => Some((tl!("Unchecked reads"), t.text_muted)), Tone::Ok => None, }; let value_font = theme::medium(13.0); @@ -614,7 +697,10 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.painter().text(pos2(value_end, bottom), Align2::RIGHT_CENTER, c, font, colour); w }); - let room = (value_end - text_x - value_w.max(caption_w) - 10.0).max(0.0); + // The name shares the top line with the value, the formula the bottom one with the + // caption. + let room = (value_end - text_x - value_w - 10.0).max(0.0); + let formula_room = (value_end - text_x - caption_w - 10.0).max(0.0); let name_font = theme::medium(13.0); let tag = row.script.then(|| (tl!("Script"), widgets::text_width(ui, tl!("Script"), &theme::semibold(9.5)) + 8.0)); let name_room = (room - tag.map_or(0.0, |(_, w)| w + 6.0)).max(0.0); @@ -625,7 +711,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); ui.painter().text(chip.center(), Align2::CENTER_CENTER, label, theme::semibold(9.5), t.text_muted); } - widgets::paint_left(ui, pos2(text_x, bottom), &row.formula, egui::FontId::monospace(11.0), t.text_muted, room); + widgets::paint_left(ui, pos2(text_x, bottom), &row.formula, egui::FontId::monospace(11.0), t.text_muted, formula_room); } // The selected field's arrows, or the one under the pointer. if editable && (is_selected || pointed) { @@ -786,7 +872,7 @@ pub(crate) fn paint_page( let fill = match row.tone { Tone::Ok => STEP, Tone::Late => LINK_LATE, - Tone::Loop => LINK_LOOP, + Tone::Loop | Tone::Missing => LINK_LOOP, }; // No bigger than the field, so a small one keeps its contents in view. let radius = (8.0 * zoom).clamp(6.5, 10.0).min((r.height() * 0.6).max(5.0)); @@ -813,7 +899,7 @@ fn arrow(painter: &egui::Painter, a: Rect, b: Rect, tone: Tone, zoom: f32) { let colour = match tone { Tone::Ok => LINK_OK, Tone::Late => LINK_LATE, - Tone::Loop => LINK_LOOP, + Tone::Loop | Tone::Missing => LINK_LOOP, }; let stroke = Stroke::new(1.75, colour); let head = 9.0; @@ -859,7 +945,7 @@ pub(crate) fn paint_trace(ui: &egui::Ui, painter: &egui::Painter, viewport: Rect } let (Some(listed), Some(focus)) = (view.current(generation), view.focus(selected)) else { return }; let t = Tokens::get(ui.ctx()); - let text = trace(&listed.links, &focus); + let text = trace(&listed.links, &focus, listed.order.fields.iter().find(|f| f.name == focus)); let font = theme::regular(12.0); // Clear of the floating tool strip at the view's left. let room = (viewport.width() - 220.0).max(0.0); @@ -938,10 +1024,40 @@ mod tests { fn the_summary_says_what_a_field_reads_and_what_reads_it() { crate::i18n::set_current(crate::i18n::Lang::EN); let links = [link("sub", "total", Tone::Late), link("tax", "total", Tone::Ok), link("qty", "sub", Tone::Ok), link("sub", "tax", Tone::Ok)]; - assert_eq!(trace(&links, "total"), "total reads sub and tax · No field reads it"); - assert_eq!(trace(&links, "sub"), "sub reads qty · total and tax read it"); - assert_eq!(trace(&links, "tax"), "tax reads sub · total reads it"); - assert_eq!(trace(&links, "qty"), "qty reads no other field · sub reads it"); + assert_eq!(trace(&links, "total", None), "total reads sub and tax · No field reads it"); + assert_eq!(trace(&links, "sub", None), "sub reads qty · total and tax read it"); + assert_eq!(trace(&links, "tax", None), "tax reads sub · total reads it"); + assert_eq!(trace(&links, "qty", None), "qty reads no other field · sub reads it"); + } + + /// A field the form doesn't have is read too, and said to be missing; a script's names built + /// as it runs are said instead of "no other field". + #[test] + fn the_summary_says_what_is_missing_and_what_is_built() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let missing = |names: &[&str]| names.iter().map(|n| CalculationIssue::MissingField { field: (*n).into() }).collect::>(); + let links = [link("qty", "sub", Tone::Ok), link("sub", "total", Tone::Ok), link("tax", "total", Tone::Ok)]; + let sub = field("sub", &["qty", "discount"], missing(&["discount"])); + assert_eq!(trace(&links, "sub", Some(&sub)), "sub reads qty and discount · discount is missing · total reads it"); + let tax = field("tax", &["subtotl", "rate"], missing(&["subtotl", "rate"])); + assert_eq!(trace(&links, "tax", Some(&tax)), "tax reads subtotl and rate · subtotl and rate are missing · total reads it"); + let rows = field("rows", &[], vec![CalculationIssue::BuiltNames]); + assert_eq!(trace(&links, "rows", Some(&rows)), "rows reads fields named as it runs · No field reads it"); + let both = field("total", &["sub", "tax"], vec![CalculationIssue::BuiltNames]); + assert_eq!(trace(&links, "total", Some(&both)), "total reads sub and tax · Some names are built as it runs · No field reads it"); + } + + /// A missing field outranks a loop on the row, but the field is still in its loop. + #[test] + fn a_missing_field_marks_the_row_and_keeps_its_loop() { + let gone = CalculationIssue::MissingField { field: "gone".into() }; + assert_eq!(tone(&[gone.clone(), CalculationIssue::Cycle { fields: vec!["b".into()] }]), Tone::Missing); + assert_eq!(tone(&[CalculationIssue::BuiltNames]), Tone::Ok, "built names are a note, not a fault"); + let calculated = [ + field("a", &["b", "gone"], vec![gone, CalculationIssue::Cycle { fields: vec!["b".into()] }]), + field("b", &["a"], vec![CalculationIssue::Cycle { fields: vec!["a".into()] }]), + ]; + assert_eq!(loops(&calculated), vec![vec!["a", "b"]]); } #[test] diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 6ce727a716..d7bde224b4 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1353,3 +1353,38 @@ fn deleting_two_radio_buttons_of_a_group_at_once() { h.run_steps(3); assert!(!names(&h).iter().any(|n| n == "shirt")); } + +/// A formula that reads a field the form doesn't have gets a card of its own, in place of "every +/// field calculates after the fields it reads", and its link opens that formula. A script that +/// builds the names it reads gets a note. +#[test] +fn a_formula_reading_a_missing_field_has_its_own_card() { + use pdfcraft_engine::form_scripts::Calculate; + use pdfcraft_engine::{Edit, FieldProps, NewField}; + use pdfcraft_ui_egui::prepare::FieldTab; + let mut h = harness(); + for (i, name) in ["qty", "discount", "sub", "rows"].into_iter().enumerate() { + let y = 200.0 + 30.0 * i as f64; + let kind = NewField::Text { multiline: false }; + h.state_mut().apply_edit(Edit::AddField { page: 0, rect: [20.0, y, 120.0, y + 20.0], kind, name: Some(name.into()) }); + } + let props = Box::new(FieldProps { calculate: Some(Calculate::Notation("qty - discount".into())), ..Default::default() }); + h.state_mut().apply_edit(Edit::SetFieldProps { name: "sub".into(), props }); + let script = Some("event.value = getField(\"row\" + 1).value;".to_owned()); + h.state_mut().apply_edit(Edit::SetFieldScript { name: "rows".into(), event: "calculate".into(), script }); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + h.get_by_label("Calculation order").click(); + h.run_steps(3); + assert!(h.query_by_label("Every field calculates after the fields it reads.").is_some(), "nothing is missing yet"); + assert!(h.query_by_label("rows builds some field names as it runs, so those reads aren't checked.").is_some()); + h.state_mut().apply_edit(Edit::DeleteField { name: "discount".into() }); + h.run_steps(3); + assert!(h.query_by_label("1 formula reads a field the form doesn't have").is_some()); + assert!(h.query_by_label("sub reads discount, which the form doesn't have.").is_some()); + assert!(h.query_by_label("Every field calculates after the fields it reads.").is_none(), "no all-clear while a formula is broken"); + h.get_by_label("Edit sub's formula").click(); + h.run_steps(3); + let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab)); + assert_eq!(draft, Some(("sub".to_owned(), FieldTab::Calculate))); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 6e929825ba..8d922f707f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -256,7 +256,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| -| 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page; `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | +| 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page (a red mark, a card naming the broken formulas, a note for built names); `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 766c439703..952b70445c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4679,8 +4679,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties", "form.calculation_order"] tools = ["form_set_props", "form_fill", "form_calculation_order"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_a_group_or_a_widget", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. Each formula is also checked against the form: a read of a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's getField) and a script that builds the names it reads at run time are flagged, in form_calculation_order (which also gives each formula) and in the page's row tooltips. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_a_group_or_a_widget", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read", "crates/ui-egui/tests/it/prepare.rs::a_formula_reading_a_missing_field_has_its_own_card"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. Each formula is also checked against the form: a read of a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's getField) and a script that builds the names it reads at run time are flagged, in form_calculation_order (which also gives each formula) and on the page: a red Missing field mark on the row and a card naming those formulas with a link to edit the first (no all-clear while one is broken), a grey note for built names, and both in the summary above the page. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] id = "form.event-order" From 6a5020760e55728df0c31a46ce97d9d1829b3ca5 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Fri, 23 Oct 2026 23:59:08 +0200 Subject: [PATCH 57/82] M6: calculation checks: formulas by field object, whole AFSimple lists only, no all-clear beside a broken formula - CalculatedField carries the field object: form_calculation_order and the Calculation order page find each row's formula and value by it, so two calculated fields sharing a name (a malformed form) each show their own. - An AFSimple_Calculate list counts as written out only when it ends the call: "row" + suffix and [...].concat(more) are names built at run time, and an array item counts only when it ends at a comma or the list's end, so ["row" + i] no longer reads, or renames, "row". - After a reorder, Undo is offered on the missing-field card instead of bringing back the all-clear beside it. From CodeRabbit's review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- crates/automation/src/forms.rs | 8 ++- crates/automation/tests/it/automation.rs | 37 ++++++++++++++ crates/forms/README.md | 3 +- crates/forms/src/af/calc.rs | 32 +++++++++--- crates/forms/src/af/tests.rs | 14 ++++++ crates/forms/src/calc_order.rs | 5 +- crates/forms/src/tests.rs | 6 +++ crates/ui-egui/src/tools/calc_order_ui.rs | 20 +++++--- crates/ui-egui/tests/it/prepare.rs | 61 +++++++++++++++++++++++ 9 files changed, 163 insertions(+), 23 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 1dec77bf03..d13f059a3f 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -667,11 +667,9 @@ impl Automation { CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }), CalculationIssue::BuiltNames => json!({ "issue": "built_names" }), }; + // By field object, not name: two calculated fields can share a name in a malformed form. let doc = self.doc(a)?; - let mut by_name: std::collections::HashMap<&str, &FormField> = std::collections::HashMap::with_capacity(doc.form.len()); - for f in doc.form.iter() { - by_name.entry(f.name.as_str()).or_insert(f); - } + let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect(); out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>()); out["fields"] = order .fields @@ -679,7 +677,7 @@ impl Automation { .map(|f| { json!({ "name": f.name, - "formula": by_name.get(f.name.as_str()).and_then(|x| calculate_json(x)), + "formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)), "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>(), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 22b5cd17e2..fd29cdce18 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5058,6 +5058,43 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`. +fn two_calculations_with_one_name() -> Vec { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>", + &widget("dup", 700, "x"), + &widget("dup", 660, "src"), + &widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>", + ]) +} + +/// Each row of the calculation order gets its own field's formula, even where two fields share a +/// name (a malformed form): the first dup sums src, the second x. +#[test] +fn the_calculation_order_gives_each_field_its_own_formula() { + let dir = workdir("calcdup"); + std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!( + formulas, + [json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })] + ); +} + /// The calculation order through the tool shows each formula, in form_set_props's shape or as a /// script, and flags a read of a field the form doesn't have and a script that builds the names /// it reads; form_fields shows a custom script too. diff --git a/crates/forms/README.md b/crates/forms/README.md index 3b5e46eee9..6d576bb7ee 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -87,7 +87,8 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table left alone. `calculations_changed` compares two readings of a form to say which formulas an edit rewrote. - The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the - order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`, + order `recalculate` runs them, each with its field object (two can share a name in a malformed + form), with the fields each formula reads (`Calculate::reads`, `af::calculation_reads`; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` entries that calculate nothing. Two issues are about the formula itself: it reads a field the diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 958b5d4d04..c6556476a6 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -277,12 +277,18 @@ fn calculation_sites(js: &str) -> Vec<(Range, Site)> { sites.extend(built(k)); continue; } + // One string of comma-separated names, when it is the whole list (not `"row" + suffix`). if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); + if punct(k + 5, ')') { + sites.push((range, Site::Commas(list))); + } else { + sites.extend(built(k)); + } continue; } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string, - // which has to close the list for the list to be written out. Nothing else is read, so no + // `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has + // to end the call. An item that is more than a string (`"row" + i`), or anything after + // the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no // token is read for two calls. let (first, close) = if punct(k + 4, '[') { (k + 5, ']') @@ -292,11 +298,21 @@ fn calculation_sites(js: &str) -> Vec<(Range, Site)> { sites.extend(built(k)); continue; }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - Js::Punct(c) if *c == close => break, + let mut j = first; + loop { + match tokens.get(j) { + // An item ends at a comma or the list's end, or where a script cut short ends. + Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => { + sites.push((range.clone(), Site::Literal(name.clone()))); + j += 1; + } + Some((_, Js::Punct(','))) => j += 1, + Some((_, Js::Punct(c))) if *c == close => { + if !punct(j + 1, ')') { + sites.extend(built(k)); + } + break; + } _ => { sites.extend(built(k)); break; diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 5198fcc5ad..50b3029ada 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -168,9 +168,23 @@ fn names_built_at_run_time_are_noticed() { "AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);", "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));", "AFSimple_Calculate(op, new Array(\"a\"));", + "AFSimple_Calculate(\"SUM\", \"row\" + suffix);", + "AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));", + "AFSimple_Calculate(\"SUM\", [\"row\" + i]);", ] { assert!(reads_built_names(js), "{js}"); } + // A string that is only the start of a name or a list isn't read, or renamed, as one. + for (js, reads) in [ + ("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]), + ] { + assert_eq!(parse_calculate(js), None, "{js} is a script"); + assert_eq!(calculation_reads(js), reads, "{js}"); + assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}"); + } for js in [ "event.value = getField(\"a\").value + getField('b').value;", "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));", diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index c4a1b5bd37..ed69ac7c1b 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -17,6 +17,9 @@ const MAX_LINKS: usize = 1 << 20; pub struct CalculatedField { /// The field's full name. pub name: String, + /// The field object. Two calculated fields can share a name in a malformed form; this tells + /// them apart. + pub obj: ObjRef, /// The fields its formula reads, each once, as the formula names them: a name also stands for /// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here. pub reads: Vec, @@ -184,7 +187,7 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) { issues.push(CalculationIssue::BuiltNames); } - out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); + out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues }); } let fixed = fixed_order(&links, &component); CalculationOrder { fields: out, extra, fixed } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 6f848f9628..c9c01c0e13 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1814,6 +1814,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { }; let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]); + // Each row is its own field object, so a caller can find the right formula for each dup. + let all = fields(&doc); + for c in calculation_order(&doc, &all).fields { + let f = all.iter().find(|f| f.obj == c.obj).unwrap(); + assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone())); + } let current: Vec = ["dup", "src", "dup"].map(String::from).into(); set_calculation_order(&mut doc, ¤t).unwrap(); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged"); diff --git a/crates/ui-egui/src/tools/calc_order_ui.rs b/crates/ui-egui/src/tools/calc_order_ui.rs index bc80983021..d2b952df3e 100644 --- a/crates/ui-egui/src/tools/calc_order_ui.rs +++ b/crates/ui-egui/src/tools/calc_order_ui.rs @@ -139,16 +139,14 @@ fn one_line(text: &str) -> String { text.split_whitespace().collect::>().join(" ").chars().take(200).collect() } -/// Each calculated field's value, formula and standing, in the order's order. +/// Each calculated field's value, formula and standing, in the order's order. Fields are found by +/// their object, not their name: two calculated fields can share a name in a malformed form. fn rows(calculated: &[CalculatedField], form: &[FormField]) -> Vec { - let mut by_name: HashMap<&str, &FormField> = HashMap::with_capacity(form.len()); - for f in form { - by_name.entry(f.name.as_str()).or_insert(f); - } + let by_obj: HashMap<_, &FormField> = form.iter().map(|f| (f.obj, f)).collect(); calculated .iter() .map(|c| { - let field = by_name.get(c.name.as_str()); + let field = by_obj.get(&c.obj); let value = match field { Some(f) if f.kind == FormFieldKind::Text => f.value.first().map(|v| format_value(&f.actions.format, v)).unwrap_or_default(), Some(f) => f.display_value(), @@ -451,6 +449,11 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, { act = Some(Act::EditFormula((*first).to_owned())); } + // The all-clear, which carries Undo after a reorder, stays hidden while a formula is + // broken, so Undo is offered here. + if undo && text_button(ui, tl!("Undo"), t) { + act = Some(Act::Undo); + } }); ui.add_space(6.0); } @@ -503,7 +506,7 @@ fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, ui.add(egui::Label::new(egui::RichText::new(tl!("Moves only what must move")).font(theme::regular(11.5)).color(t.text_muted)).wrap()); }); }); - } else if missing.is_empty() || undo { + } else if missing.is_empty() { let (fill, edge, colour) = pal.good; card(ui, fill, edge, |ui| { let said = if loops.is_empty() { @@ -965,7 +968,8 @@ mod tests { use super::*; fn field(name: &str, reads: &[&str], issues: Vec) -> CalculatedField { - CalculatedField { name: name.into(), reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues } + let obj = pdfcraft_cos::ObjRef { num: 0, generation: 0 }; + CalculatedField { name: name.into(), obj, reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues } } #[test] diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index d7bde224b4..3fad521e68 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1354,6 +1354,56 @@ fn deleting_two_radio_buttons_of_a_group_at_once() { assert!(!names(&h).iter().any(|n| n == "shirt")); } +/// Two calculated fields that share a name (a malformed form) each show their own formula: the +/// first dup sums src, the second x. +#[test] +fn fields_sharing_a_name_each_show_their_own_formula() { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(), + widget("dup", 700, "x"), + widget("dup", 660, "src"), + widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("dup.pdf", None, bytes.clone()).unwrap(); + app + }); + h.run_steps(4); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + h.get_by_label("Calculation order").click(); + h.run_steps(3); + for (row, formula) in [("1. dup", "= sum(src)"), ("3. dup", "= sum(x)")] { + h.get_by_label_contains(row).hover(); + h.run_steps(30); + assert!(h.query_by_label_contains(formula).is_some(), "{row} shows {formula}"); + } +} + /// A formula that reads a field the form doesn't have gets a card of its own, in place of "every /// field calculates after the fields it reads", and its link opens that formula. A script that /// builds the names it reads gets a note. @@ -1383,6 +1433,17 @@ fn a_formula_reading_a_missing_field_has_its_own_card() { assert!(h.query_by_label("1 formula reads a field the form doesn't have").is_some()); assert!(h.query_by_label("sub reads discount, which the form doesn't have.").is_some()); assert!(h.query_by_label("Every field calculates after the fields it reads.").is_none(), "no all-clear while a formula is broken"); + // Reordered, the order can be undone from the missing-field card; the all-clear still stays away. + h.state_mut().apply_edit(Edit::SetCalculationOrder { names: vec!["rows".into(), "sub".into()] }); + h.run_steps(3); + assert!(h.query_by_label("Every field calculates after the fields it reads.").is_none(), "still no all-clear"); + h.get_by_label("Undo").click(); + h.run_steps(3); + let order: Vec = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().calculation_order().fields.into_iter().map(|f| f.name).collect() + }; + assert_eq!(order, ["sub", "rows"], "Undo put the order back"); h.get_by_label("Edit sub's formula").click(); h.run_steps(3); let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab)); From 64c26761a4065e9ffe6184a581dd6601f8efc3b3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 24 Oct 2026 00:39:33 +0200 Subject: [PATCH 58/82] M6: form_set_props refuses a calculate script given beside a notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculate_arg read the notation first, so {"notation": …, "script": …} set the notation and dropped the script unnoticed; the script check now comes first (from CodeRabbit's review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --- crates/automation/src/forms.rs | 7 ++++--- crates/automation/tests/it/automation.rs | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index d13f059a3f..3399505ecc 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -113,12 +113,13 @@ fn calculate_arg(v: &Value) -> Result { return Ok(Calculate::None); } let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?; - if let Some(n) = o.get("notation").and_then(Value::as_str) { - return Ok(Calculate::Notation(n.to_string())); - } + // Before anything else is read, so a script given beside a notation isn't dropped unnoticed. if o.contains_key("script") { return Err(bad("a custom calculate script is set with form_set_script (event: calculate)")); } + if let Some(n) = o.get("notation").and_then(Value::as_str) { + return Ok(Calculate::Notation(n.to_string())); + } let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { "sum" => CalcOp::Sum, "product" => CalcOp::Product, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index fd29cdce18..f46187d3e7 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5123,9 +5123,12 @@ fn the_calculation_order_shows_formulas_and_what_they_cannot_read() { let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap(); assert_eq!(tax["calculate"], json!({ "script": script })); - // A script is set with form_set_script, and form_set_props says so. - let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": { "script": script } })); - assert!(e.to_string().contains("form_set_script"), "{e}"); + // A script is set with form_set_script, and form_set_props says so, also when a notation + // comes with it. + for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate })); + assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}"); + } } #[test] From 36e16de470056cfa48096dde1390e179f858a5c3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 00:30:57 +0200 Subject: [PATCH 59/82] M6: form_fields and form_calculation_order name product and average as form_set_props takes them calculate_json wrote the lowercased AFSimple code, so a product or an average came back as {"op": "prd"} or {"op": "avg"}, which form_set_props refused as an unknown op. One name per operation now serves both: calculate_arg looks names up in it (still taking minimum and maximum), and the listings write it (from CodeRabbit's review). Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/forms.rs | 24 +++++++++++++++++------- crates/automation/tests/it/automation.rs | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 3399505ecc..649366de55 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -121,12 +121,11 @@ fn calculate_arg(v: &Value) -> Result { return Ok(Calculate::Notation(n.to_string())); } let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { - "sum" => CalcOp::Sum, - "product" => CalcOp::Product, - "average" => CalcOp::Average, - "min" | "minimum" => CalcOp::Minimum, - "max" | "maximum" => CalcOp::Maximum, - x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))), + "minimum" => CalcOp::Minimum, + "maximum" => CalcOp::Maximum, + x => { + CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))? + } }; let fields: Vec = o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(); @@ -136,11 +135,22 @@ fn calculate_arg(v: &Value) -> Result { Ok(Calculate::Simple { op, fields }) } +/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it. +fn op_name(op: CalcOp) -> &'static str { + match op { + CalcOp::Sum => "sum", + CalcOp::Product => "product", + CalcOp::Average => "average", + CalcOp::Minimum => "min", + CalcOp::Maximum => "max", + } +} + /// A field's calculation as `form_fields` and `form_calculation_order` show it, in /// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one. fn calculate_json(f: &FormField) -> Option { match (&f.actions.calculate, &f.actions.scripts.calculate) { - (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op.code().to_lowercase(), "fields": fields })), + (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), (Calculate::Notation(n), _) => Some(json!({ "notation": n })), (Calculate::None, Some(js)) => Some(json!({ "script": js })), (Calculate::None, None) => None, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index f46187d3e7..7778108cb8 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5131,6 +5131,28 @@ fn the_calculation_order_shows_formulas_and_what_they_cannot_read() { } } +/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes +/// it, so a formula can be read and given back: product and average came back as prd and avg, +/// which it refused. +#[test] +fn every_calculation_op_can_be_read_and_given_back() { + let dir = workdir("calcops"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + for op in ["sum", "product", "average", "min", "max"] { + let calculate = json!({ "op": op, "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone(); + let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown })); + } +} + #[test] fn page_tab_orders_are_read_and_set_per_page_through_tools() { let dir = workdir("pagetabs"); From 91a4db27be3df9df2246d7977c78204f9d6a2a03 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 00:30:57 +0200 Subject: [PATCH 60/82] M6: calculation checks: a widget number past a field's last widget is a missing field A read like price.12 counted as found whenever price existed. A widget number now finds something only below the field's widget count; a field with one widget, or none, still answers to .0 (from CodeRabbit's review). Co-Authored-By: Claude Opus 5.5 --- crates/forms/src/calc_order.rs | 38 +++++++++++++++++++++------------- crates/forms/src/tests.rs | 6 ++++-- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index ed69ac7c1b..3b4a684824 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -161,7 +161,7 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .collect(); let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); let component = components(&links); - let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + let mut all: Vec<(&str, usize)> = fields.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); all.sort_unstable(); let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); let mut out = Vec::with_capacity(calculated.len()); @@ -193,14 +193,22 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { CalculationOrder { fields: out, extra, fixed } } -/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name, a -/// field under it (`row` for `row.1`), or a widget of one (`price.0`, as `getField` numbers them). -fn found(names: &[&str], name: &str) -> bool { - let has = |n: &str| names.binary_search(&n).is_ok(); +/// Whether a formula's read `name` finds a field in `fields` (names with their widget counts, +/// sorted): the field of that name, a field under it (`row` for `row.1`), or one of a field's +/// widgets (`price.0`, as Acrobat's `getField` numbers them, up to its last widget). +fn found(fields: &[(&str, usize)], name: &str) -> bool { + // The most widgets a field called `n` has (a malformed form can have two such fields). + let widgets = |n: &str| { + let at = fields.partition_point(|(f, _)| *f < n); + fields.get(at..).unwrap_or_default().iter().take_while(|(f, _)| *f == n).map(|&(_, w)| w).max() + }; let prefix = format!("{name}."); - let under = names.partition_point(|n| *n < prefix.as_str()); - let widget = name.rsplit_once('.').is_some_and(|(base, i)| !i.is_empty() && i.bytes().all(|b| b.is_ascii_digit()) && has(base)); - has(name) || names.get(under).is_some_and(|n| n.starts_with(&prefix)) || widget + let under = fields.partition_point(|(f, _)| *f < prefix.as_str()); + // A field with one widget, or none, is still `.0` (digits only: `parse` would take `+0`). + let widget = name.rsplit_once('.').is_some_and(|(base, i)| { + i.bytes().all(|b| b.is_ascii_digit()) && i.parse::().is_ok_and(|i| widgets(base).is_some_and(|w| i < w.max(1))) + }); + widgets(name).is_some() || fields.get(under).is_some_and(|(f, _)| f.starts_with(&prefix)) || widget } /// For each calculated field (by its place in `names`), the places of the calculated fields it @@ -410,14 +418,16 @@ mod tests { #[test] fn a_read_name_finds_a_field_a_group_or_a_widget() { - let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; - names.sort_unstable(); - for name in ["price", "row", "row.1", "rows", "price.0", "price.12"] { - assert!(found(&names, name), "{name}"); + let mut fields = vec![("price", 1), ("qty", 3), ("row.1", 1), ("row.2", 1), ("rows", 1), ("total", 1), ("bare", 0)]; + fields.sort_unstable(); + for name in ["price", "row", "row.1", "rows", "price.0", "qty.0", "qty.2", "bare", "bare.0"] { + assert!(found(&fields, name), "{name}"); } - for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "total.0.1", ""] { - assert!(!found(&names, name), "{name}"); + // Past a field's last widget there is no widget to find. + for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "price.+0", "price.1", "price.12", "qty.3", "bare.1", "total.0.1", ""] { + assert!(!found(&fields, name), "{name}"); } + assert!(!found(&fields, "qty.99999999999999999999999"), "a number too big for usize is no widget"); } /// Two thousand formulas each reading a group of two thousand fields would be four million diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index c9c01c0e13..ae73da1a7d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1727,7 +1727,9 @@ fn missing_fields_and_built_names_are_flagged() { let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); - set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"qty\").value + getField(\"ghost\").value;")).unwrap(); + // qty has one widget: qty.0 is it, and there is no qty.1. + let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;"; + set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap(); set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); let issues = |doc: &Document| { let order = calculation_order(doc, &fields(doc)); @@ -1738,7 +1740,7 @@ fn missing_fields_and_built_names_are_flagged() { vec![ ("sum".to_owned(), sum), ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), - ("script".to_owned(), vec![MissingField { field: "ghost".into() }]), + ("script".to_owned(), vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.1".into() }]), ("built".to_owned(), vec![BuiltNames]), ] }; From 2b40413dc300884aaee6e5d71d44233dbe2be930 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 10:16:10 +0100 Subject: [PATCH 61/82] M6: calculation checks: an Acrobat widget name is a missing field, as nothing here resolves it found accepted price.0 (up to the field's last widget), but links, recalculate and the scripts' getField all take a name as the field or the fields under it, so a formula reading price.0 got no value and no order check while the page said nothing was wrong. found now looks names up the same way; a widget name is flagged as missing until all of them resolve it (from CodeRabbit's review). Co-Authored-By: Claude Opus 5.5 --- crates/forms/README.md | 5 +++-- crates/forms/src/calc_order.rs | 41 +++++++++++++--------------------- crates/forms/src/tests.rs | 8 +++++-- parity/acrobat-features.toml | 2 +- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/crates/forms/README.md b/crates/forms/README.md index 6d576bb7ee..e3c5f7824b 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -92,8 +92,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table `af::calculation_reads`; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` entries that calculate nothing. Two issues are about the formula itself: it reads a field the - form doesn't have (`MissingField`; the field of that name, a field under it and a widget of one, - `price.0`, all count as there), or its script builds names at run time (`BuiltNames`, + form doesn't have (`MissingField`; the field of that name and a field under it count as there, + as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`, + which neither resolves), or its script builds names at run time (`BuiltNames`, `af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological sort that keeps the current order wherever it can and leaves loops as they are. `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index 3b4a684824..cbafd296e2 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -161,7 +161,7 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .collect(); let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); let component = components(&links); - let mut all: Vec<(&str, usize)> = fields.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); + let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); all.sort_unstable(); let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); let mut out = Vec::with_capacity(calculated.len()); @@ -193,22 +193,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { CalculationOrder { fields: out, extra, fixed } } -/// Whether a formula's read `name` finds a field in `fields` (names with their widget counts, -/// sorted): the field of that name, a field under it (`row` for `row.1`), or one of a field's -/// widgets (`price.0`, as Acrobat's `getField` numbers them, up to its last widget). -fn found(fields: &[(&str, usize)], name: &str) -> bool { - // The most widgets a field called `n` has (a malformed form can have two such fields). - let widgets = |n: &str| { - let at = fields.partition_point(|(f, _)| *f < n); - fields.get(at..).unwrap_or_default().iter().take_while(|(f, _)| *f == n).map(|&(_, w)| w).max() - }; +/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a +/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts' +/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so +/// a read of one gets no value and is missing too. +fn found(names: &[&str], name: &str) -> bool { let prefix = format!("{name}."); - let under = fields.partition_point(|(f, _)| *f < prefix.as_str()); - // A field with one widget, or none, is still `.0` (digits only: `parse` would take `+0`). - let widget = name.rsplit_once('.').is_some_and(|(base, i)| { - i.bytes().all(|b| b.is_ascii_digit()) && i.parse::().is_ok_and(|i| widgets(base).is_some_and(|w| i < w.max(1))) - }); - widgets(name).is_some() || fields.get(under).is_some_and(|(f, _)| f.starts_with(&prefix)) || widget + let under = names.partition_point(|n| *n < prefix.as_str()); + names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix)) } /// For each calculated field (by its place in `names`), the places of the calculated fields it @@ -417,17 +409,16 @@ mod tests { } #[test] - fn a_read_name_finds_a_field_a_group_or_a_widget() { - let mut fields = vec![("price", 1), ("qty", 3), ("row.1", 1), ("row.2", 1), ("rows", 1), ("total", 1), ("bare", 0)]; - fields.sort_unstable(); - for name in ["price", "row", "row.1", "rows", "price.0", "qty.0", "qty.2", "bare", "bare.0"] { - assert!(found(&fields, name), "{name}"); + fn a_read_name_finds_a_field_or_a_group() { + let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; + names.sort_unstable(); + for name in ["price", "row", "row.1", "rows"] { + assert!(found(&names, name), "{name}"); } - // Past a field's last widget there is no widget to find. - for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "price.+0", "price.1", "price.12", "qty.3", "bare.1", "total.0.1", ""] { - assert!(!found(&fields, name), "{name}"); + // A widget name finds nothing: recalculation and getField don't resolve it either. + for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] { + assert!(!found(&names, name), "{name}"); } - assert!(!found(&fields, "qty.99999999999999999999999"), "a number too big for usize is no widget"); } /// Two thousand formulas each reading a group of two thousand fields would be four million diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ae73da1a7d..0909132a0f 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1727,7 +1727,8 @@ fn missing_fields_and_built_names_are_flagged() { let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); - // qty has one widget: qty.0 is it, and there is no qty.1. + // qty has one widget, but a widget name is no field: recalculation and getField give qty.0 + // nothing, so it is as missing as qty.1. let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;"; set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap(); set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); @@ -1740,7 +1741,10 @@ fn missing_fields_and_built_names_are_flagged() { vec![ ("sum".to_owned(), sum), ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), - ("script".to_owned(), vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.1".into() }]), + ( + "script".to_owned(), + vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }], + ), ("built".to_owned(), vec![BuiltNames]), ] }; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 952b70445c..3b7713a3da 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4679,7 +4679,7 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties", "form.calculation_order"] tools = ["form_set_props", "form_fill", "form_calculation_order"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_a_group_or_a_widget", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read", "crates/ui-egui/tests/it/prepare.rs::a_formula_reading_a_missing_field_has_its_own_card"] +evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat", "crates/forms/src/tests.rs::the_calculation_order_is_checked_fixed_and_kept", "crates/forms/src/tests.rs::a_calculation_order_with_gaps_and_extra_entries", "crates/forms/src/tests.rs::two_fields_with_one_name_keep_their_places_in_the_order", "crates/engine/src/tests.rs::setting_the_calculation_order_recalculates_and_undoes", "crates/automation/tests/it/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/it/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed", "crates/engine/src/tests.rs::checkbox_and_rank_changes_recalculate_ordered_dependents", "crates/engine/src/tests.rs::dynamic_calculation_reads_and_side_effects_reach_later_fields", "crates/forms/src/tests.rs::missing_fields_and_built_names_are_flagged", "crates/forms/src/calc_order.rs::a_read_name_finds_a_field_or_a_group", "crates/forms/src/af/tests.rs::names_built_at_run_time_are_noticed", "crates/automation/tests/it/automation.rs::the_calculation_order_shows_formulas_and_what_they_cannot_read", "crates/ui-egui/tests/it/prepare.rs::a_formula_reading_a_missing_field_has_its_own_card"] notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. Order editing: Prepare a form ▸ Calculation order lists the calculated fields in order with their formulas and values, Up/Down arrows (as Acrobat's Set Field Calculation Order dialog), Alt+Up/Down and drag, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and Fix order moves only what must move; on the page it numbers each calculated field's step and draws arrows from the fields the focused field reads and to the fields that read it; form_calculation_order reads, sets, moves and fixes it. Each formula is also checked against the form: a read of a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's getField) and a script that builds the names it reads at run time are flagged, in form_calculation_order (which also gives each formula) and on the page: a red Missing field mark on the row and a card naming those formulas with a link to edit the first (no all-clear while one is broken), a grey note for built names, and both in the summary above the page. A new calculation joins the end of /CO and an edited one keeps its place. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] From 49d5c40356f3bd77fa8005d2aa0f7736908f193a Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 16:40:04 +0100 Subject: [PATCH 62/82] M6: calculation checks: a row or widget in focus is its own field, not the first of its name The page's focus was a name, so with two fields called total (a malformed form) hovering the second one's row showed the first one's missing fields in the pill, and outlined and numbered both on the page. A row and a widget under the pointer now carry their field object: the pill says that field's reads and issues, and the page outlines it, its step and the formulas that read it by object. The Prepare selection is still only a name, so it stands for every field of that name and the pill says all of their issues (from FredApps's review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/tools/calc_order_ui.rs | 207 +++++++++++++++++----- 2 files changed, 164 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 91d2c41367..6de219e893 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -914,7 +914,7 @@ struct PageFrame<'a> { current_overlap: f32, current_height: f32, pressed_page: Option, - calc_hover: Option, + calc_hover: Option, } /// Visible heights (points) closer than this are equal: one page size can meet the viewport a @@ -1586,8 +1586,8 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } if calc_order { let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(name); + if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); } } } else { diff --git a/crates/ui-egui/src/tools/calc_order_ui.rs b/crates/ui-egui/src/tools/calc_order_ui.rs index d2b952df3e..932420e663 100644 --- a/crates/ui-egui/src/tools/calc_order_ui.rs +++ b/crates/ui-egui/src/tools/calc_order_ui.rs @@ -18,6 +18,7 @@ use crate::canvas::PageToolContext; use crate::theme::{self, Tokens}; use crate::{LeftPanel, PdfCraftApp, icons, widgets}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Vec2, pos2, vec2}; +use pdfcraft_cos::ObjRef; use pdfcraft_engine::form_scripts::{CalcOp, Calculate, format_value}; use pdfcraft_engine::{CalculatedField, CalculationIssue, CalculationOrder, Edit, FormField, FormFieldKind}; @@ -54,9 +55,9 @@ enum Tone { pub struct CalcOrderView { cache: Option<(u64, Arc)>, /// The field whose row is under the pointer, this frame. - hover_row: Option, + hover_row: Option, /// The field under the pointer on the page, last frame. - hover_page: Option, + hover_page: Option, /// Links on page is off. hide_links: bool, /// The selected field the list last scrolled to, so a new selection (on the page too) brings @@ -88,9 +89,47 @@ struct Row { struct Link { from: String, to: String, + /// The calculated field `to` names, whose formula reads `from`. + reader: ObjRef, tone: Tone, } +/// The field in focus: its name, and its object when the focus is one field (its row, or its +/// widget on the page). The Prepare selection has only a name, so it stands for every field of +/// that name: two fields can share a name in a malformed form. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Focus { + name: String, + obj: Option, +} + +impl Focus { + fn named(name: &str) -> Self { + Self { name: name.to_owned(), obj: None } + } + + /// A calculated field's row. + fn row(f: &CalculatedField) -> Self { + Self { name: f.name.clone(), obj: Some(f.obj) } + } + + /// Whether field `name`, object `obj`, is in focus. + fn is(&self, name: &str, obj: ObjRef) -> bool { + self.name == name && self.obj.is_none_or(|o| o == obj) + } + + /// `l` brings a value into the focus: its formula reads `l.from`. + fn reads(&self, l: &Link) -> bool { + self.is(&l.to, l.reader) + } + + /// `l` takes the focus's value to a formula. A formula reads by name, so it reads every field + /// of the focus's name alike. + fn read_by(&self, l: &Link) -> bool { + l.from == self.name + } +} + impl CalcOrderView { fn listed(&mut self, doc: &pdfcraft_engine::Document) -> Arc { let generation = doc.edit_generation(); @@ -112,13 +151,13 @@ impl CalcOrderView { } /// The field under the pointer on the page, for the next frame. - pub(crate) fn set_hover_page(&mut self, name: Option) { - self.hover_page = name; + pub(crate) fn set_hover_page(&mut self, field: Option) { + self.hover_page = field; } /// The field whose links show: under the pointer in the list or on the page, else selected. - fn focus(&self, selected: Option<&str>) -> Option { - self.hover_row.clone().or_else(|| self.hover_page.clone()).or_else(|| selected.map(str::to_owned)) + fn focus(&self, selected: Option<&str>) -> Option { + self.hover_row.clone().or_else(|| self.hover_page.clone()).or_else(|| selected.map(Focus::named)) } } @@ -200,7 +239,7 @@ fn links(calculated: &[CalculatedField], mut names: Vec<&str>) -> Vec { } else { Tone::Ok }; - out.push(Link { from: from.to_owned(), to: c.name.clone(), tone }); + out.push(Link { from: from.to_owned(), to: c.name.clone(), reader: c.obj, tone }); } } } @@ -256,25 +295,31 @@ fn names(list: &[&str]) -> String { } /// What the focused field reads and what reads it: "total reads sub and tax · No field reads it". -/// `calculated` is the focus in the order, if it calculates: the fields it reads that the form -/// doesn't have, and names its script builds as it runs, are said too. -fn trace(links: &[Link], focus: &str, calculated: Option<&CalculatedField>) -> String { +/// If the focus calculates (its fields in the order's `calculated`), the fields it reads that the +/// form doesn't have, and names its script builds as it runs, are said too. +fn trace(links: &[Link], focus: &Focus, calculated: &[CalculatedField]) -> String { let lang = crate::i18n::current(); let mut reads: Vec<&str> = Vec::new(); let mut read_by: Vec<&str> = Vec::new(); for l in links { - if l.to == focus && !reads.contains(&l.from.as_str()) { + if focus.reads(l) && !reads.contains(&l.from.as_str()) { reads.push(&l.from); } - if l.from == focus && !read_by.contains(&l.to.as_str()) { + if focus.read_by(l) && !read_by.contains(&l.to.as_str()) { read_by.push(&l.to); } } - let issues = calculated.map(|c| c.issues.as_slice()).unwrap_or_default(); - let missing: Vec<&str> = - issues.iter().filter_map(|i| if let CalculationIssue::MissingField { field } = i { Some(field.as_str()) } else { None }).collect(); - let built = issues.contains(&CalculationIssue::BuiltNames); + let (mut missing, mut said): (Vec<&str>, HashSet<&str>) = (Vec::new(), HashSet::new()); + let mut built = false; + for issue in calculated.iter().filter(|c| focus.is(&c.name, c.obj)).flat_map(|c| &c.issues) { + match issue { + CalculationIssue::MissingField { field } if said.insert(field) => missing.push(field), + CalculationIssue::BuiltNames => built = true, + _ => {} + } + } reads.extend(&missing); + let focus = focus.name.as_str(); let mut parts = vec![if !reads.is_empty() { crate::i18n::fmt(tl!("{field} reads {fields}"), &[("field", focus), ("fields", &names(&reads))]) } else if built { @@ -661,14 +706,14 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } let pointed = ui.rect_contains_pointer(rect); if pointed { - hover_row = Some(f.name.clone()); + hover_row = Some(Focus::row(f)); } let visible = ui.is_rect_visible(rect); // The arrows' column, kept for every row so nothing moves when they show. let arrows_x = rect.right() - 26.0; if visible { let press = theme::Press::track(ui, &resp); - if !is_selected && focus_page.as_deref() == Some(f.name.as_str()) { + if !is_selected && focus_page.as_ref().is_some_and(|p| p.is(&f.name, f.obj)) { ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); } press.wash(ui, rect, 8, is_selected); @@ -815,6 +860,16 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } } +/// The focus's links, and whether a field is at either end of one or is the focus itself: the +/// fields it reads go by name, as a read name stands for every field of that name, and the +/// formulas that read it by object. +fn focus_links<'a>(links: &'a [Link], focus: &'a Focus) -> (Vec<&'a Link>, impl Fn(&str, ObjRef) -> bool + 'a) { + let shown: Vec<&Link> = links.iter().filter(|l| focus.reads(l) || focus.read_by(l)).collect(); + let names: HashSet<&str> = shown.iter().filter(|l| focus.reads(l)).map(|l| l.from.as_str()).collect(); + let readers: HashSet = shown.iter().filter(|l| focus.read_by(l)).map(|l| l.reader).collect(); + (shown, move |name: &str, obj: ObjRef| focus.is(name, obj) || names.contains(name) || readers.contains(&obj)) +} + /// The calculation order on page `page`: the links of the field in focus, with the fields it has /// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in /// focus, the steps of the fields whose place is wrong. A form with many calculations stays @@ -825,48 +880,50 @@ pub(crate) fn paint_page( view: &CalcOrderView, generation: u64, selected: Option<&str>, -) -> Option { +) -> Option { let PageToolContext { ui, xf, page, info, ptr, .. } = cx; let painter = ui.painter(); let pointer = ptr.pos; let listed = view.current(generation)?; // Each field's first widget on this page. - let mut on_page: Vec<(&str, Rect)> = Vec::new(); + let mut on_page: Vec<(&str, ObjRef, Rect)> = Vec::new(); for f in form { if let Some(w) = f.widgets.iter().find(|w| w.page == Some(page) && !w.hidden) { let r = w.rect; - on_page.push((&f.name, xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]))); + on_page.push((&f.name, f.obj, xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]))); } } if on_page.is_empty() { return None; } - let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned()); - let at: HashMap<&str, Rect> = on_page.iter().copied().collect(); + let hovered = pointer + .and_then(|p| on_page.iter().rev().find(|(_, _, r)| r.contains(p))) + .map(|(name, obj, _)| Focus { name: (*name).to_owned(), obj: Some(*obj) }); + // Arrows start at a read name's field (one of them, if fields share it) and end at the formula's. + let by_name: HashMap<&str, Rect> = on_page.iter().map(|(name, _, r)| (*name, *r)).collect(); + let by_obj: HashMap = on_page.iter().map(|(_, obj, r)| (*obj, *r)).collect(); let zoom = xf.zoom().clamp(0.5, 3.0); let focus = if view.hide_links { None } else { view.focus(selected) }; - // The focus's links, and the fields at either end of them (with the focus itself). - let shown: Vec<&Link> = focus.as_deref().map(|f| listed.links.iter().filter(|l| l.from == f || l.to == f).collect()).unwrap_or_default(); - let related: Option> = focus.as_deref().map(|f| shown.iter().flat_map(|l| [l.from.as_str(), l.to.as_str()]).chain([f]).collect()); - if let (Some(focus), Some(related)) = (focus.as_deref(), &related) { - for (name, r) in &on_page { - if !related.contains(name) { + let focused = focus.as_ref().map(|f| focus_links(&listed.links, f)); + if let (Some(focus), Some((shown, related))) = (&focus, &focused) { + for (name, obj, r) in &on_page { + if !related(name, *obj) { painter.rect_filled(r.expand(1.0), CornerRadius::ZERO, Color32::from_white_alpha(150)); } else { - let width = if *name == focus { 2.0 } else { 1.0 }; + let width = if focus.is(name, *obj) { 2.0 } else { 1.0 }; painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(width, LINK_OK), egui::StrokeKind::Outside); } } - for l in &shown { - if let (Some(a), Some(b)) = (at.get(l.from.as_str()), at.get(l.to.as_str())) { + for l in shown { + if let (Some(a), Some(b)) = (by_name.get(l.from.as_str()), by_obj.get(&l.reader)) { arrow(painter, *a, *b, l.tone, zoom); } } } for (k, (f, row)) in listed.order.fields.iter().zip(&listed.rows).enumerate() { - let Some(r) = at.get(f.name.as_str()) else { continue }; - let shown = match &related { - Some(related) => related.contains(f.name.as_str()), + let Some(r) = by_obj.get(&f.obj) else { continue }; + let shown = match &focused { + Some((_, related)) => related(f.name.as_str(), f.obj), None => row.tone != Tone::Ok || !f.listed, }; if !shown { @@ -948,7 +1005,7 @@ pub(crate) fn paint_trace(ui: &egui::Ui, painter: &egui::Painter, viewport: Rect } let (Some(listed), Some(focus)) = (view.current(generation), view.focus(selected)) else { return }; let t = Tokens::get(ui.ctx()); - let text = trace(&listed.links, &focus, listed.order.fields.iter().find(|f| f.name == focus)); + let text = trace(&listed.links, &focus, &listed.order.fields); let font = theme::regular(12.0); // Clear of the floating tool strip at the view's left. let room = (viewport.width() - 220.0).max(0.0); @@ -968,7 +1025,7 @@ mod tests { use super::*; fn field(name: &str, reads: &[&str], issues: Vec) -> CalculatedField { - let obj = pdfcraft_cos::ObjRef { num: 0, generation: 0 }; + let obj = ObjRef { num: 0, generation: 0 }; CalculatedField { name: name.into(), obj, reads: reads.iter().map(|r| (*r).to_owned()).collect(), listed: true, issues } } @@ -983,7 +1040,7 @@ mod tests { } fn link(from: &str, to: &str, tone: Tone) -> Link { - Link { from: from.into(), to: to.into(), tone } + Link { from: from.into(), to: to.into(), reader: ObjRef { num: 0, generation: 0 }, tone } } #[test] @@ -1028,10 +1085,10 @@ mod tests { fn the_summary_says_what_a_field_reads_and_what_reads_it() { crate::i18n::set_current(crate::i18n::Lang::EN); let links = [link("sub", "total", Tone::Late), link("tax", "total", Tone::Ok), link("qty", "sub", Tone::Ok), link("sub", "tax", Tone::Ok)]; - assert_eq!(trace(&links, "total", None), "total reads sub and tax · No field reads it"); - assert_eq!(trace(&links, "sub", None), "sub reads qty · total and tax read it"); - assert_eq!(trace(&links, "tax", None), "tax reads sub · total reads it"); - assert_eq!(trace(&links, "qty", None), "qty reads no other field · sub reads it"); + assert_eq!(trace(&links, &Focus::named("total"), &[]), "total reads sub and tax · No field reads it"); + assert_eq!(trace(&links, &Focus::named("sub"), &[]), "sub reads qty · total and tax read it"); + assert_eq!(trace(&links, &Focus::named("tax"), &[]), "tax reads sub · total reads it"); + assert_eq!(trace(&links, &Focus::named("qty"), &[]), "qty reads no other field · sub reads it"); } /// A field the form doesn't have is read too, and said to be missing; a script's names built @@ -1042,13 +1099,71 @@ mod tests { let missing = |names: &[&str]| names.iter().map(|n| CalculationIssue::MissingField { field: (*n).into() }).collect::>(); let links = [link("qty", "sub", Tone::Ok), link("sub", "total", Tone::Ok), link("tax", "total", Tone::Ok)]; let sub = field("sub", &["qty", "discount"], missing(&["discount"])); - assert_eq!(trace(&links, "sub", Some(&sub)), "sub reads qty and discount · discount is missing · total reads it"); + assert_eq!(trace(&links, &Focus::named("sub"), &[sub]), "sub reads qty and discount · discount is missing · total reads it"); let tax = field("tax", &["subtotl", "rate"], missing(&["subtotl", "rate"])); - assert_eq!(trace(&links, "tax", Some(&tax)), "tax reads subtotl and rate · subtotl and rate are missing · total reads it"); + assert_eq!(trace(&links, &Focus::named("tax"), &[tax]), "tax reads subtotl and rate · subtotl and rate are missing · total reads it"); let rows = field("rows", &[], vec![CalculationIssue::BuiltNames]); - assert_eq!(trace(&links, "rows", Some(&rows)), "rows reads fields named as it runs · No field reads it"); + assert_eq!(trace(&links, &Focus::named("rows"), &[rows]), "rows reads fields named as it runs · No field reads it"); let both = field("total", &["sub", "tax"], vec![CalculationIssue::BuiltNames]); - assert_eq!(trace(&links, "total", Some(&both)), "total reads sub and tax · Some names are built as it runs · No field reads it"); + assert_eq!(trace(&links, &Focus::named("total"), &[both]), "total reads sub and tax · Some names are built as it runs · No field reads it"); + } + + /// Two calculated fields named `total`, as a malformed form can have, each reading a field the + /// form doesn't have, and `grand` reading `total`. + fn two_totals() -> ([CalculatedField; 3], Vec) { + let total = |num, reads: &[&str], gone: &str| CalculatedField { + obj: ObjRef { num, generation: 0 }, + ..field("total", reads, vec![CalculationIssue::MissingField { field: gone.into() }]) + }; + let grand = CalculatedField { obj: ObjRef { num: 3, generation: 0 }, ..field("grand", &["total"], vec![]) }; + let calculated = [total(1, &["qty", "lost"], "lost"), total(2, &["tax", "gone"], "gone"), grand]; + let links = links(&calculated, vec!["qty", "tax", "total", "total", "grand"]); + (calculated, links) + } + + /// Hovering a row of a field that shares its name says that field's reads and missing fields, + /// not the first one's of that name. The selection is only a name, so it says both. + #[test] + fn a_row_sharing_its_name_is_traced_by_its_field() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let (calculated, links) = two_totals(); + let mut view = CalcOrderView::default(); + let mut said = Vec::new(); + for f in &calculated { + view.hover_row = Some(Focus::row(f)); + let focus = view.focus(Some("total")).expect("a hovered row is the focus"); + said.push(trace(&links, &focus, &calculated)); + } + assert_eq!( + said, + [ + "total reads qty and lost · lost is missing · grand reads it", + "total reads tax and gone · gone is missing · grand reads it", + "grand reads total · No field reads it", + ] + ); + view.hover_row = None; + let selected = view.focus(Some("total")).expect("the selection is the focus"); + assert_eq!(selected, Focus::named("total")); + assert_eq!(trace(&links, &selected, &calculated), "total reads qty, tax, lost and gone · lost and gone are missing · grand reads it"); + } + + /// On the page too, a field that shares its name is outlined, and its arrows drawn, as one + /// field: the fields it reads by name, the formulas that read it by object. + #[test] + fn a_field_sharing_its_name_is_related_on_the_page_by_its_object() { + let (calculated, links) = two_totals(); + let [first, second, grand] = calculated.each_ref().map(|f| f.obj); + let elsewhere = ObjRef { num: 9, generation: 0 }; + let focus = Focus::row(&calculated[0]); + let (shown, related) = focus_links(&links, &focus); + assert!(shown.iter().all(|l| l.reader == first || l.to == "grand"), "{shown:?}"); + assert!(related("total", first) && !related("total", second), "only the field in focus"); + assert!(related("qty", elsewhere) && !related("tax", elsewhere), "only the fields it reads"); + assert!(related("grand", grand), "the formula that reads it"); + let selected = Focus::named("total"); + let (_, related) = focus_links(&links, &selected); + assert!(related("total", first) && related("total", second) && related("tax", elsewhere), "a selected name is every field of it"); } /// A missing field outranks a loop on the row, but the field is still in its loop. From 1808720d751d4a699ac6ad688814f25af6798615 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:12:41 +0100 Subject: [PATCH 63/82] M6: calculation checks: an AFSimple string list with an argument after it is read and renamed, as an array is AFSimple_Calculate("SUM", "a, b", x) gave only a built-name site, so a rename no longer rewrote a and b (main did) while parse_calculate still read the sum of a and b: after a rename the sum read missing fields. The string list now keeps its names when an argument follows, and what follows is a name built at run time, as for an array (from the review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/forms/src/af/calc.rs | 7 +++++-- crates/forms/src/af/tests.rs | 6 ++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index c6556476a6..d29956cbc2 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -278,10 +278,13 @@ fn calculation_sites(js: &str) -> Vec<(Range, Site)> { continue; } // One string of comma-separated names, when it is the whole list (not `"row" + suffix`). + // An argument after it leaves the list as written, as after an array: its names are read, + // and what follows is a name built at run time. if let Some((range, list)) = string(k + 4) { - if punct(k + 5, ')') { + if punct(k + 5, ')') || punct(k + 5, ',') { sites.push((range, Site::Commas(list))); - } else { + } + if !punct(k + 5, ')') { sites.extend(built(k)); } continue; diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 50b3029ada..851c5c032e 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -199,6 +199,12 @@ fn names_built_at_run_time_are_noticed() { let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));"; assert_eq!(calculation_reads(js), ["a"]); assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\""))); + // So are a list's, string or array, with another argument after it. + for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] { + assert!(reads_built_names(js), "{js}"); + assert_eq!(calculation_reads(js), ["a", "b"], "{js}"); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}"); + } } /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in From 50faef001aea8d8d5835d94a2d6ab4b6e8f8c097 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:12:41 +0100 Subject: [PATCH 64/82] M6: calculation checks: CalculatedField::issues says it covers the formula too MissingField and BuiltNames are about the formula, not its place in the order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/forms/src/calc_order.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index cbafd296e2..62c16ce67d 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -25,7 +25,7 @@ pub struct CalculatedField { pub reads: Vec, /// `/CO` lists it. A field it leaves out calculates after the listed ones. pub listed: bool, - /// What is wrong with its place in the order. + /// What is wrong with its formula or its place in the order. pub issues: Vec, } From c97bf570292b091a7319cd69501226cf2c215979 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:12:41 +0100 Subject: [PATCH 65/82] M6: form_set_props refuses unknown, mistyped and conflicting keys in calculate and validate A notation beside op and fields set the notation and dropped the rest, a field name that wasn't a string was left out, a misspelt key was ignored, and validate {"min": 0, "max": "100"} wrote a min-only range. Each is now refused and nothing changes. A null bound, as form_fields shows the one a range doesn't have, is no bound, so its output is taken back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/automation/src/forms.rs | 32 ++++++++++++++---- crates/automation/src/tools.rs | 2 +- crates/automation/tests/it/automation.rs | 41 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 649366de55..78da146fa0 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result { }) } -/// `{"min": 0, "max": 100}` or `"none"`. +/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it. fn validate_arg(v: &Value) -> Result { if v.as_str() == Some("none") { return Ok(Validate::None); } let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?; - let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64)); + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) { + return Err(bad(format!("unknown key {k:?} in validate (min, max)"))); + } + let bound = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))), + }; + let (min, max) = (bound("min")?, bound("max")?); if min.is_none() && max.is_none() { return Err(bad("validate needs min, max or both")); } @@ -117,18 +124,31 @@ fn calculate_arg(v: &Value) -> Result { if o.contains_key("script") { return Err(bad("a custom calculate script is set with form_set_script (event: calculate)")); } - if let Some(n) = o.get("notation").and_then(Value::as_str) { + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) { + return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)"))); + } + // A null is no value, as for the tool's own arguments. + let get = |k: &str| o.get(k).filter(|v| !v.is_null()); + if let Some(n) = get("notation") { + if get("op").is_some() || get("fields").is_some() { + return Err(bad("calculate takes a notation, or op and fields, not both")); + } + let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?; return Ok(Calculate::Notation(n.to_string())); } - let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { + let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?; + let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? { "minimum" => CalcOp::Minimum, "maximum" => CalcOp::Maximum, x => { CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))? } }; - let fields: Vec = - o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?; + let fields: Vec = fields + .iter() + .map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)"))) + .collect::>()?; if fields.is_empty() { return Err(bad("calculate needs fields")); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index b41e114442..ceb64fa173 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -492,7 +492,7 @@ pub fn tools() -> Vec { "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, - "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, + "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc"], diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7778108cb8..6745c895cb 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5153,6 +5153,47 @@ fn every_calculation_op_can_be_read_and_given_back() { } } +/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't +/// know, a value of the wrong type, a notation beside op and fields) and changes nothing. A +/// validate read back from form_fields, with null for the bound it doesn't have, is taken back. +#[test] +fn calculate_and_validate_refuse_what_they_would_drop() { + let dir = workdir("calcstrict"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let set = json!({ "op": "sum", "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } })); + let total = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc })); + fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone() + }; + for (calculate, says) in [ + (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"), + (json!({ "op": "sum", "fields": ["a", 5] }), "field names"), + (json!({ "op": "sum", "fields": "a" }), "list of field names"), + (json!({ "notation": 5 }), "notation must be a string"), + (json!({ "op": 5, "fields": ["a"] }), "op must be"), + (json!({ "fields": ["a"] }), "needs op"), + (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""), + ] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + assert!(e.to_string().contains(says), "{calculate}: {e}"); + } + for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate })); + assert!(e.to_string().contains(says), "{validate}: {e}"); + } + let t = total(&mut a); + assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } })); + let shown = total(&mut a)["validate"].clone(); + assert_eq!(shown, json!({ "min": null, "max": 5.0 })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown })); +} + #[test] fn page_tab_orders_are_read_and_set_per_page_through_tools() { let dir = workdir("pagetabs"); From 50a4a19a1ad058405ce6a77cee903178231af1f8 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:12:41 +0100 Subject: [PATCH 66/82] M6: calculation checks: Finnish and Persian text Both translate the neighbouring Calculation order rows; Finnish had English copies of the new ones, and Persian came in with main. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/ui-egui/src/i18n/fa.tsv | 11 +++++++++++ crates/ui-egui/src/i18n/fi.tsv | 23 +++++++++++------------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 5cfc217533..21efeb6444 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -538,6 +538,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} را می‌خواند که به نوبه خود آن را می‌خوانند. هیچ ترتیبی حلقه را اصلاح نمی‌کند: یکی از فرمول‌ها را تغییر دهید. Not in order نامرتب Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. در ترتیب محاسب‌های که در PDF ذخیره شده وجود ندارد: سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند. + Reads {name}, which the form doesn't have, so it gets no value there. {name} را می‌خواند که در فرم وجود ندارد، پس از آن مقداری نمی‌گیرد. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. اسکریپت آن فیلد‌هایی را هم می‌خواند که نامشان را هنگام اجرا می‌سازد، پس این خواندن‌ها قابل بررسی نیستند. + Missing field فیلد ناموجود + Unchecked reads خواندن‌های بررسی‌نشده + {field} reads fields named as it runs {field} فیلد‌هایی را می‌خواند که نامشان هنگام اجرا ساخته می‌شود + Some names are built as it runs برخی نام‌ها هنگام اجرا ساخته می‌شوند Back to Prepare a form بازگشت به آماده‌سازی فرم No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. هنوز هیچ فیلدی در این فرم مقدارش را محاسبه نمی‌کند. به یک فیلد در برگه محاسبه از ویژگی‌هایش یک محاسبه بدهید تا اینجا نمایش داده شود. {names} and {last} {names} و {last} @@ -2514,6 +2520,11 @@ animation effects Off خاموش @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} پیش از {fields} محاسبه می‌شود، در حالی که آن را می‌خواند.|{field} پیش از {fields} محاسبه می‌شود، در حالی که آن‌ها را می‌خواند. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} فیلد دیگر زود‌تر از موعد محاسبه می‌شود.|{n} فیلد دیگر زود‌تر از موعد محاسبه می‌شوند. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} در ترتیب ذخیره‌شده در PDF وجود ندارد، بنابراین سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.|{fields} در ترتیب ذخیره‌شده در PDF وجود ندارند، بنابراین سایر نمایشگر‌های PDF ممکن است آن‌ها را محاسبه نکنند. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} فرمول فیلدی را می‌خواند که در فرم وجود ندارد|{n} فرمول فیلد‌هایی را می‌خوانند که در فرم وجود ندارند +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} {fields} را می‌خواند که در فرم وجود ندارد.|{field} {fields} را می‌خواند که در فرم وجود ندارند. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} فرمول دیگر یک فیلد ناموجود را می‌خواند.|{n} فرمول دیگر فیلد‌های ناموجود را می‌خوانند. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} برخی نام‌های فیلد را هنگام اجرا می‌سازد، پس این خواندن‌ها بررسی نمی‌شوند.|{fields} برخی نام‌های فیلد را هنگام اجرا می‌سازند، پس این خواندن‌ها بررسی نمی‌شوند. +@plural {fields} is missing|{fields} are missing {fields} وجود ندارد|{fields} وجود ندارند Scan اسکن Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. روی یک برگ کاغذ سفید امضا کنید و از آن در نور روشن و یکنواخت عکس بگیرید (از فلاش استفاده کنید). عکس را انتخاب کنید یا آن را اینجا رها کنید: PdfCraft امضا را پیدا می‌کند و کاغذ را حذف می‌کند. Choose photo… انتخاب عکس… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 1d4e6c7d79..e18d18d560 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2732,6 +2732,12 @@ theme Appearance Teema Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista. Not in order Ei järjestyksessä Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä. + Reads {name}, which the form doesn't have, so it gets no value there. Lukee kenttää {name}, jota lomakkeessa ei ole, joten se ei saa siitä arvoa. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sen komentosarja lukee myös kenttiä, joiden nimet se muodostaa suorituksen aikana, joten näitä viittauksia ei voi tarkistaa. + Missing field Puuttuva kenttä + Unchecked reads Tarkistamattomat viittaukset + {field} reads fields named as it runs {field} lukee kenttiä, joiden nimet muodostuvat suorituksen aikana + Some names are built as it runs Osa nimistä muodostuu suorituksen aikana Back to Prepare a form Takaisin kohtaan Valmistele lomake No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä. {names} and {last} {names} ja {last} @@ -2759,6 +2765,11 @@ theme Appearance Teema @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} kaava lukee kenttää, jota lomakkeessa ei ole|{n} kaavaa lukee kenttiä, joita lomakkeessa ei ole +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lukee kenttää {fields}, jota lomakkeessa ei ole.|{field} lukee kenttiä {fields}, joita lomakkeessa ei ole. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} muu kaava lukee puuttuvaa kenttää.|{n} muuta kaavaa lukee puuttuvia kenttiä. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} muodostaa osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta.|{fields} muodostavat osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta. +@plural {fields} is missing|{fields} are missing {fields} puuttuu|{fields} puuttuvat Bulleted list Luettelomerkit Numbered list Numeroitu luettelo Alignment Tasaus @@ -2783,18 +2794,6 @@ theme Appearance Teema The signature's signed attributes have no message digest, so the signature does not cover the document. Allekirjoituksen allekirjoitetuista määritteistä puuttuu viestitiiviste, joten allekirjoitus ei kata asiakirjaa. The signature's signed attributes name a content type other than data, so it is not a signature of the document. Allekirjoituksen allekirjoitetut määritteet nimeävät muun sisältötyypin kuin data, joten se ei ole asiakirjan allekirjoitus. Remove {standard} identification Poista {standard}-tunniste -# Untranslated: English text until a translator fills these in. - Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. - Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. - Missing field Missing field - Unchecked reads Unchecked reads - {field} reads fields named as it runs {field} reads fields named as it runs - Some names are built as it runs Some names are built as it runs -@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have -@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. -@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. -@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. -@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko From fefb99a786a5533ab3d7bc160eb9594c8f9ebeab Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:12:41 +0100 Subject: [PATCH 67/82] M6: calculation checks: the page's steps are chosen by a function the tests reach steps() takes the widgets by object, so each of two fields sharing a name gets its own step; a test checks it, unfocused and with a focus (from the review: no test failed with the step looked up by name). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/ui-egui/src/tools/calc_order_ui.rs | 53 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/tools/calc_order_ui.rs b/crates/ui-egui/src/tools/calc_order_ui.rs index 932420e663..ce25d6c0a8 100644 --- a/crates/ui-egui/src/tools/calc_order_ui.rs +++ b/crates/ui-egui/src/tools/calc_order_ui.rs @@ -920,16 +920,9 @@ pub(crate) fn paint_page( } } } - for (k, (f, row)) in listed.order.fields.iter().zip(&listed.rows).enumerate() { - let Some(r) = by_obj.get(&f.obj) else { continue }; - let shown = match &focused { - Some((_, related)) => related(f.name.as_str(), f.obj), - None => row.tone != Tone::Ok || !f.listed, - }; - if !shown { - continue; - } - let fill = match row.tone { + let related = focused.as_ref().map(|(_, related)| related as Related<'_>); + for (k, r, tone) in steps(&listed, &by_obj, related) { + let fill = match tone { Tone::Ok => STEP, Tone::Late => LINK_LATE, Tone::Loop | Tone::Missing => LINK_LOOP, @@ -943,6 +936,27 @@ pub(crate) fn paint_page( hovered } +/// Whether a field, by name and object, is at an end of the focus's links or is the focus. +type Related<'a> = &'a dyn Fn(&str, ObjRef) -> bool; + +/// The steps the page shows: each one's place in the order (from 0), the widget it goes on (its +/// own field's, so fields that share a name each get theirs) and its tone. With a focus, the +/// steps of the fields `related` to it; without one, those whose place is wrong. +fn steps(listed: &Listed, by_obj: &HashMap, related: Option>) -> Vec<(usize, Rect, Tone)> { + listed + .order + .fields + .iter() + .zip(&listed.rows) + .enumerate() + .filter(|(_, (f, row))| match related { + Some(related) => related(&f.name, f.obj), + None => row.tone != Tone::Ok || !f.listed, + }) + .filter_map(|(k, (f, row))| Some((k, *by_obj.get(&f.obj)?, row.tone))) + .collect() +} + /// Where a ray from `r`'s centre in direction `dir` (a unit vector) leaves `r`. fn edge(r: Rect, dir: Vec2) -> Pos2 { let half = r.size() / 2.0; @@ -1166,6 +1180,25 @@ mod tests { assert!(related("total", first) && related("total", second) && related("tax", elsewhere), "a selected name is every field of it"); } + /// Each step goes on its own field's widget: two fields named `total` get one each, and a + /// focus on one shows its step and its reader's, not the other's. + #[test] + fn a_step_goes_on_its_own_field_when_names_are_shared() { + let (calculated, links) = two_totals(); + let [first, second, grand] = calculated.each_ref().map(|f| f.obj); + let at = |x: f32| Rect::from_min_size(pos2(x, 0.0), vec2(10.0, 10.0)); + let by_obj = HashMap::from([(first, at(0.0)), (second, at(100.0)), (grand, at(200.0))]); + let rows = rows(&calculated, &[]); + let mut order = CalculationOrder::default(); + order.fields = calculated.to_vec(); + let listed = Listed { order, rows, links }; + // Unfocused, the two broken formulas show their steps. + assert_eq!(steps(&listed, &by_obj, None), [(0, at(0.0), Tone::Missing), (1, at(100.0), Tone::Missing)]); + let focus = Focus::row(&calculated[0]); + let (_, related) = focus_links(&listed.links, &focus); + assert_eq!(steps(&listed, &by_obj, Some(&related)), [(0, at(0.0), Tone::Missing), (2, at(200.0), Tone::Ok)]); + } + /// A missing field outranks a loop on the row, but the field is still in its loop. #[test] fn a_missing_field_marks_the_row_and_keeps_its_loop() { From fd3634cc38f3859f9c784ea63812f6134fdbc258 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:27:08 +0100 Subject: [PATCH 68/82] M6: form_set_props refuses a notation it couldn't store as written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A notation is kept in the script's /** BVCALC … EVCALC **/ comment and read back from there. One holding EVCALC was stored cut short ("a EVCALC b" read back as "a", and its JavaScript translation didn't parse), one holding */ ended the comment, and an empty one became "event.value = ;". form_set_props now refuses each (af::notation_storable: it has to read back as itself). The test also pins a notation beside op alone and beside fields alone (from the review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/automation/src/forms.rs | 6 +++++- crates/automation/tests/it/automation.rs | 6 ++++++ crates/forms/src/af.rs | 2 +- crates/forms/src/af/script.rs | 9 +++++++++ crates/forms/src/af/tests.rs | 10 ++++++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 78da146fa0..a9add477f2 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,7 +1,7 @@ //! Form tools: list fields, fill them (several at once, one undo step), clear the form, and //! prepare a form (add fields one at a time or as a table, change and delete them). -use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate}; +use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate, notation_storable}; use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; @@ -134,6 +134,10 @@ fn calculate_arg(v: &Value) -> Result { return Err(bad("calculate takes a notation, or op and fields, not both")); } let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?; + // Refused rather than stored as a different formula. + if !notation_storable(n) { + return Err(bad("the notation can't be stored as written: it is empty, or holds EVCALC or */, which would end it early")); + } return Ok(Calculate::Notation(n.to_string())); } let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?; diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6745c895cb..1504dee7a3 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5172,6 +5172,12 @@ fn calculate_and_validate_refuse_what_they_would_drop() { }; for (calculate, says) in [ (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"), + (json!({ "notation": "a", "op": "sum" }), "not both"), + (json!({ "notation": "a", "fields": ["b"] }), "not both"), + // A notation that wouldn't read back as itself from the script's comment. + (json!({ "notation": "a EVCALC b" }), "can't be stored"), + (json!({ "notation": "a */ x /*" }), "can't be stored"), + (json!({ "notation": " " }), "can't be stored"), (json!({ "op": "sum", "fields": ["a", 5] }), "field names"), (json!({ "op": "sum", "fields": "a" }), "list of field names"), (json!({ "notation": 5 }), "notation must be a string"), diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 00cfc99d12..c83ce29cf3 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -22,7 +22,7 @@ use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; use script::{Arg, call}; -pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; +pub use script::{calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 9aed297b45..1544258a2c 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -325,6 +325,15 @@ pub fn validate_js(v: &Validate) -> Option { } } +/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment, +/// between `BVCALC` and `EVCALC`, and it has to read back from there as itself. An empty one, or +/// one holding `EVCALC` or `*/` (either ends it early), can't. +pub fn notation_storable(expr: &str) -> bool { + !expr.trim().is_empty() + && calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)) + == Some(Calculate::Notation(expr.trim().to_owned())) +} + pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 851c5c032e..d0246d3016 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -217,6 +217,16 @@ fn hostile_scripts_are_read_in_one_pass() { assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); } +/// A notation is stored only when it reads back from the script's comment as itself. +#[test] +fn a_notation_is_stored_only_when_it_reads_back() { + assert!(notation_storable("Price * Qty")); + assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read"); + for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b"] { + assert!(!notation_storable(expr), "{expr:?}"); + } +} + /// Notation is read through one lexer: field names with escapes, numbers and operators. #[test] fn notation_is_read_by_one_lexer() { From d7c8e6ad1bda51d6b374cc7b043ca12b2de9784e Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:37:58 +0100 Subject: [PATCH 69/82] M6: calculation checks: a notation's last name keeps the escaped space that ends it parse_calculate trimmed the notation with str::trim, which also took an escaped space ending its last name: "Qty * Total\ " read back as "Qty * Total\", a field the form doesn't have, while the script's JavaScript still read "Total ". It now trims to its first and last token, as the field-reading scan does. notation_storable compares the names and operators read back with those given, so a notation ending in a lone \ (which escapes the space after it) is refused; a proptest reads back notations of any names (from the review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/automation/src/forms.rs | 4 +-- .../forms/proptest-regressions/af/tests.txt | 7 +++++ crates/forms/src/af.rs | 2 +- crates/forms/src/af/script.rs | 28 ++++++++++++++----- crates/forms/src/af/tests.rs | 17 ++++++++++- 5 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 crates/forms/proptest-regressions/af/tests.txt diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index a9add477f2..eb38cb2b4e 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,7 +1,7 @@ //! Form tools: list fields, fill them (several at once, one undo step), clear the form, and //! prepare a form (add fields one at a time or as a table, change and delete them). -use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate, notation_storable}; +use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable}; use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; @@ -136,7 +136,7 @@ fn calculate_arg(v: &Value) -> Result { let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?; // Refused rather than stored as a different formula. if !notation_storable(n) { - return Err(bad("the notation can't be stored as written: it is empty, or holds EVCALC or */, which would end it early")); + return Err(bad(NOTATION_NOT_STORABLE)); } return Ok(Calculate::Notation(n.to_string())); } diff --git a/crates/forms/proptest-regressions/af/tests.txt b/crates/forms/proptest-regressions/af/tests.txt new file mode 100644 index 0000000000..70c4d0b525 --- /dev/null +++ b/crates/forms/proptest-regressions/af/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "] diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index c83ce29cf3..86c7971c31 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -22,7 +22,7 @@ use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; use script::{Arg, call}; -pub use script::{calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; +pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 1544258a2c..f15714c411 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,4 +1,4 @@ -use super::calc::{js_tokens, notation_js, notation_range}; +use super::calc::{js_tokens, notation_js, notation_range, notation_tokens}; use super::format::{DATE_PRESETS, TIME_PRESETS}; use super::{CalcOp, Calculate, Format, Validate}; @@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option { if js.contains("BVCALC") && let Some(range) = notation_range(js, &js_tokens(js).1) { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); + return Some(Calculate::Notation(notation_text(js.get(range)?).to_string())); } let args = call(js, "AFSimple_Calculate")?; let op = match args.first().and_then(Arg::string)?.as_str() { @@ -325,13 +325,27 @@ pub fn validate_js(v: &Validate) -> Option { } } +/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it. +pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\"; + /// Whether simplified field notation `expr` can be stored: the script keeps it in a comment, -/// between `BVCALC` and `EVCALC`, and it has to read back from there as itself. An empty one, or -/// one holding `EVCALC` or `*/` (either ends it early), can't. +/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and +/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the +/// space after it. An empty one is refused too: it would be stored as `event.value = ;`. pub fn notation_storable(expr: &str) -> bool { - !expr.trim().is_empty() - && calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)) - == Some(Calculate::Notation(expr.trim().to_owned())) + let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::>(); + let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)); + !tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr)) +} + +/// `src` from its first notation token to its last: the spaces around it left out, but not an +/// escaped space that ends the last name (`Total\ `), as `str::trim` would. +fn notation_text(src: &str) -> &str { + let tokens = notation_tokens(src); + match (tokens.first(), tokens.last()) { + (Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(), + _ => "", + } } pub fn calculate_js(c: &Calculate) -> Option { diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index d0246d3016..79c5c7be02 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -222,7 +222,12 @@ fn hostile_scripts_are_read_in_one_pass() { fn a_notation_is_stored_only_when_it_reads_back() { assert!(notation_storable("Price * Qty")); assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read"); - for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b"] { + // A last name that ends in a space keeps it, escaped, when the notation is read back. + let total = "Qty * Total\\ "; + assert!(notation_storable(total)); + let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap()); + assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()])); + for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] { assert!(!notation_storable(expr), "{expr:?}"); } } @@ -274,6 +279,16 @@ proptest::proptest! { proptest::prop_assert_eq!(back, Some(js)); } + /// A notation of any names, written into a script, reads back from it as written. + #[test] + fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let expr = names.iter().map(|n| notation_name(n)).collect::>().join(" + "); + let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap(); + proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone()))); + proptest::prop_assert!(notation_storable(&expr), "{expr:?}"); + } + /// What a formula reads is the same whether it is read from the tab's settings or from /// the script they are written as. #[test] From 46382092f1cd767780008328c4f933e29d316ac3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:37:58 +0100 Subject: [PATCH 70/82] M6: Field Properties refuses a notation it couldn't store, and stays open with what was typed set_props refuses such a notation before writing anything, so the Calculate tab can't store one either (the tool already refused it). A refused edit used to close Field Properties and lose everything typed into it; the dialog now stays open with its changes, as the dialog for several fields does, and the notice says why (from the review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/forms/src/author/edit.rs | 6 +++++ crates/ui-egui/src/dialogs.rs | 10 ++++++--- crates/ui-egui/tests/it/prepare.rs | 36 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 54a05f3db5..4f1a1a8632 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -88,6 +88,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "sub").unwrap().actions.calculate.clone() + }; + h.state_mut().open_field_props("sub", 0); + h.run_steps(3); + let d = h.state_mut().field_props.as_mut().expect("Field Properties is open"); + d.calculate = Calculate::Notation("qty EVCALC 2".into()); + d.tooltip = "Subtotal".into(); + h.get_by_label("OK").click(); + h.run_steps(3); + let kept = h.state().field_props.as_ref().map(|d| (d.calculate.clone(), d.tooltip.clone())); + assert_eq!(kept, Some((Calculate::Notation("qty EVCALC 2".into()), "Subtotal".to_owned())), "the dialog stays open as typed"); + assert_eq!(formula(&h), Calculate::Notation("qty * 2".into()), "the field keeps its formula"); + h.state_mut().field_props.as_mut().expect("still open").calculate = Calculate::Notation("qty * 3".into()); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state().field_props.is_none(), "applied and closed"); + assert_eq!(formula(&h), Calculate::Notation("qty * 3".into())); +} + /// A formula that reads a field the form doesn't have gets a card of its own, in place of "every /// field calculates after the fields it reads", and its link opens that formula. A script that /// builds the names it reads gets a note. From f64c71f65f9f0584e5426cadcf6de72566275972 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 17:48:37 +0100 Subject: [PATCH 71/82] M6: a test that set_props refuses an unstorable notation before writing anything The tooltip given with it, which set_props writes before the calculation, isn't set either; with the check moved to the calculation the test fails (from the review). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/forms/src/tests.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 0909132a0f..95afee7c99 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1711,6 +1711,28 @@ fn the_calculation_order_is_checked_fixed_and_kept() { assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); } +/// `set_props` refuses a notation it couldn't store as written before it writes anything: the +/// tooltip given with it, which is written before the calculation, isn't set either. +#[test] +fn a_notation_that_cannot_be_stored_changes_nothing() { + use crate::af::Calculate; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + for n in ["", "a EVCALC b", "a */ b", "a\\"] { + let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() }; + assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}"); + let all = fields(&doc); + let f = field(&all, "total"); + assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing"); + } +} + /// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it /// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a /// script that builds the names it reads. Neither is an order Fix order can fix. From 2055f48f5f6b5b41b67f8deb3305da74b5a199cd Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sun, 25 Oct 2026 19:39:41 +0100 Subject: [PATCH 72/82] M6: calculation checks: English copies of the new rows in the catalogues that had none ckb and nb get all eleven rows, th and lo the six strings, and sq the five plurals beside its strings, each under "# Untranslated". Their rows next to these were missing too, so following them had added nothing. hi, id, ko, lo and th list one plural form (plural_none), so they get no English copy of the plurals: one English wording would be used for every count ("1 formulas read ..."). Without an entry, the English fallback picks the right form, as it already does for the page's other plurals in those catalogues. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/ui-egui/src/i18n/ckb.tsv | 13 +++++++++++++ crates/ui-egui/src/i18n/lo.tsv | 8 ++++++++ crates/ui-egui/src/i18n/nb.tsv | 13 +++++++++++++ crates/ui-egui/src/i18n/sq.tsv | 5 +++++ crates/ui-egui/src/i18n/th.tsv | 8 ++++++++ 5 files changed, 47 insertions(+) diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 71c0c62869..e97cb2f7a6 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2749,3 +2749,16 @@ certificate Version وەشان Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە + +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. +@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 964467d823..219815d160 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2465,3 +2465,11 @@ animation effects Off ປິດ Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… Delete the selected pages ລຶບໜ້າທີ່ເລືອກ + +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 6f1739974a..96d5be7dee 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2733,3 +2733,16 @@ key Space Mellomrom Rotate the selected pages clockwise Roter de valgte sidene med klokken Extract the selected pages… Trekk ut de valgte sidene… Delete the selected pages Slett de valgte sidene + +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. +@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index b6fb44e607..76c7e51bda 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2415,6 +2415,11 @@ animation effects Off Joaktiv Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields. +@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. +@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing Mobile Mobile Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 2c9b892ed7..8949dcda00 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2720,3 +2720,11 @@ theme Appearance ลักษณะที่ปรากฏ Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา Extract the selected pages… แยกหน้าที่เลือกออก… Delete the selected pages ลบหน้าที่เลือก + +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + Unchecked reads Unchecked reads + {field} reads fields named as it runs {field} reads fields named as it runs + Some names are built as it runs Some names are built as it runs From ebad7f849d9dc04f8aa819efd4cfb06460de3d79 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Tue, 27 Oct 2026 19:37:12 +0100 Subject: [PATCH 73/82] M6: form_set_props' refusals are argument errors, so the tool's own notation guard is tested The test only checked each refusal's message. Without the tool's notation guard, set_props refuses the same notation with the same message as a failed edit, so the test passed with the guard removed. It now asserts InvalidArgs for every row, and fails on "a EVCALC b" without the guard. Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/it/automation.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 1504dee7a3..040d20ae57 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -5154,8 +5154,9 @@ fn every_calculation_op_can_be_read_and_given_back() { } /// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't -/// know, a value of the wrong type, a notation beside op and fields) and changes nothing. A -/// validate read back from form_fields, with null for the bound it doesn't have, is taken back. +/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before +/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the +/// bound it doesn't have, is taken back. #[test] fn calculate_and_validate_refuse_what_they_would_drop() { let dir = workdir("calcstrict"); @@ -5185,12 +5186,14 @@ fn calculate_and_validate_refuse_what_they_would_drop() { (json!({ "fields": ["a"] }), "needs op"), (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""), ] { + // InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not + // only by the edit underneath it. let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); - assert!(e.to_string().contains(says), "{calculate}: {e}"); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}"); } for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] { let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate })); - assert!(e.to_string().contains(says), "{validate}: {e}"); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}"); } let t = total(&mut a); assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed"); From e18d1bfed38272a33fad1b79b7452494a9f4dfd6 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Thu, 29 Oct 2026 22:37:03 +0100 Subject: [PATCH 74/82] =?UTF-8?q?M11:=20Export=20=E2=96=B8=20Images=20deco?= =?UTF-8?q?des=20JBIG2=20globals=20within=2032=20MiB=20of=20their=20own?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image's /JBIG2Globals were decoded with the 1 GiB any stream may decode to, before the JBIG2 page's size is known, so the page's pixel limit didn't bound them: a few kilobytes of Flate could make export allocate up to 1 GiB. They now decode within MAX_GLOBALS (32 MiB); globals past it skip the image, with the limit in the reason. The test covers one byte over and exactly the limit, and the image's /DecodeParms direct, by reference and in an array. Co-Authored-By: Claude Opus 5.5 --- crates/create/README.md | 5 ++-- crates/create/src/extract.rs | 12 ++++++-- crates/create/src/tests.rs | 55 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/create/README.md b/crates/create/README.md index 05395656a3..8cab2a1ea6 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -27,8 +27,9 @@ Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more -pixels than an exported image may have, is skipped; such a mask is left out and the image exported opaque, as JPX -and CCITT masks are. +pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the +page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image +exported opaque, as JPX and CCITT masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index b46f74570f..f7fd7f122f 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -125,15 +125,23 @@ fn decode_parms(doc: &Document, s: &Stream) -> Dict { p.as_dict().cloned().unwrap_or_default() } +/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder +/// shares between pages, themselves coded, so far less than any stream may decode to +/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. +const MAX_GLOBALS: usize = 32 << 20; + /// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller /// checked against the export's size limit, and within that limit itself: the decoder decodes the /// whole page, rows past the image's height too, so a small `/Height` over a far taller page /// would cost more than the limit allows. Rows past the image's height are dropped, and too few /// rows are refused by the caller. Globals that can't be read refuse the image, rather than -/// decoding it without them. +/// decoding it without them. They are decoded before the page's size is known, so the page's +/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. fn jbig2(doc: &Document, s: &Stream) -> Result, String> { let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { - Some(Object::Stream(g)) => Some(doc.with_direct_parms(g).decoded().map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?), + Some(Object::Stream(g)) => { + Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) + } _ => None, }; let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 98fbfd7eee..9bff0c311c 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1519,3 +1519,58 @@ fn jbig2_images_export_as_their_picture() { let alpha: Vec = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect(); assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); } + +/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have +/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to +/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes +/// in the file. Globals of exactly the limit are read, and the limit is the same through an +/// indirect `/DecodeParms`, in an array or not. +#[test] +fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { + const LIMIT: usize = 32 << 20; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a + // decoder skips; its 11-byte header gives the length of the data after it. + let globals = |len: usize| { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.resize(len, 0); + pdfcraft_filters::encode_flate(&g) + }; + // The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or + // by reference in an array (2). + let page = |flated: &[u8], form: usize| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let parms = match form { + 0 => Object::Dict(parms), + 1 => Object::Ref(doc.add(Object::Dict(parms))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]), + }; + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), parms); + Stream::from_raw(d, jbig2.clone()) + }); + doc + }; + + let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1)); + assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len()); + assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit"); + for form in 0..3 { + assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}"); + let out = extract_images(&page(&over, form), &[0], 0); + assert!(out.images.is_empty(), "globals over the limit, form {form}"); + let why = &out.skipped.first().expect("the image is skipped").2; + assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 69a05410de..7bdf89ecad 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7164,8 +7164,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, or whose globals decode to more than 32 MiB, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" From 9edd08ce0758ef9b4031f45d41ad8e5578744177 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 10:27:25 +0100 Subject: [PATCH 75/82] M11: JBIG2 symbol coding for black-and-white images pdfcraft_filters::encode_jbig2 stores each distinct mark of a 1-bit image (8-connected black pixels, at most 256 a side) once, as a symbol in a symbol dictionary, and places its exact copies with an immediate lossless text region that refers to it. Larger marks, and in a second plan marks that occur once, go to a generic region over their box. It keeps whichever of that and the generic coding (encode_jbig2_generic, unchanged) is smaller. Only exact copies share a symbol, so the stream decodes to the same bits. Written from the JBIG committee's ISO/IEC 14492 Final Committee Draft: 6.4 and 6.5 (text region and symbol dictionary decoding), 7.2, 7.4.2 and 7.4.3 (segment headers and syntax) and Annex A (the integer coders, checked against its worked example and Table A.1). On six pages of rendered 300 dpi text the output is 28% of the generic coding's size and 17% of Group 4's, at about 100 ms a page; hayro-jbig2 and Poppler (pdfimages) both decode it to the original bits. Scans, whose copies of a letter are seldom identical, gain little: that needs refinement coding. A dictionary shared between pages (/JBIG2Globals) is a further step. The optimizer's Monochrome Images > JBIG2 (lossless) and doc_optimize mono jbig2 use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- ROADMAP.md | 1 + crates/filters/README.md | 10 +- crates/filters/src/jbig2.rs | 147 ++++-- crates/filters/src/jbig2/symbols.rs | 776 ++++++++++++++++++++++++++++ crates/filters/src/lib.rs | 4 +- crates/optimize/README.md | 11 +- crates/optimize/src/images.rs | 5 +- crates/optimize/src/lib.rs | 5 +- crates/optimize/tests/optimize.rs | 60 +++ docs/pdf-spec-parity.md | 5 +- docs/roadmap.md | 2 + parity/acrobat-features.toml | 4 +- 12 files changed, 965 insertions(+), 65 deletions(-) create mode 100644 crates/filters/src/jbig2/symbols.rs diff --git a/ROADMAP.md b/ROADMAP.md index f2fa3e25a6..6ae5a40a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -200,6 +200,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged. - **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. diff --git a/crates/filters/README.md b/crates/filters/README.md index 98363e33dc..0113e22179 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,7 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder. JBIG2 generic region encoding is written here from the JBIG committee's ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999), the public draft of ITU-T T.88; its Annex H.2 test sequence checks the arithmetic coder byte for byte. +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder. JBIG2 encoding (generic regions, symbol dictionaries and text regions) is written here from the JBIG committee's ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999), the public draft of ITU-T T.88; its Annex H.2 test sequence checks the arithmetic coder byte for byte, and its Annex A example the integer coders. - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -23,7 +23,8 @@ decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*part encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS -encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // the same as one generic region ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -43,7 +44,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1 | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by an immediate lossless text region referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 symbols or 16 MiB of symbol pixels, or more than 2^24 rows, and the generic coding is used | ## Tests @@ -53,8 +54,9 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1 - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2 and CCITT decode, DCT encode), CCITT Group 3 encoding, JBIG2 symbol coding (text regions, symbol dictionaries, lossy) and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2 and CCITT decode, DCT encode), CCITT Group 3 encoding, JBIG2 refinement coding (near-copies of a symbol), symbol dictionaries shared between images (`/JBIG2Globals`) and lossy JBIG2, and Flate encode levels other than 6. diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index 8983c335fd..d3b37e5aef 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -1,8 +1,10 @@ -//! JBIG2 generic region encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` -//! (ISO 32000-2 §7.4.7): lossless, template 0 with its nominal adaptive pixels, typical prediction -//! on, MQ arithmetic coding ([`crate::mq`]). Written from the standard's 6.2.5 (coding a generic -//! region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 (the segments) and D.3 (the embedded -//! organisation PDF uses). +//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless: +//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from +//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 +//! (the segments) and D.3 (the embedded organisation PDF uses). + +mod symbols; use crate::FilterError; use crate::mq::{Context, MqEncoder}; @@ -41,6 +43,23 @@ const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1]; /// Columns of padding either side of a row, so every template pixel of a row's pixels is in it. const PAD: usize = 4; +/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings: +/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a +/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded +/// organisation and decodes to the same bits without `/JBIG2Globals`. +/// +/// Errors: as [`encode_jbig2_generic`]. +pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let generic = encode_jbig2_generic(data, columns, rows)?; + // `encode_jbig2_generic` checked that the rows are there. + let stride = (columns as usize).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)); + Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { + Some(coded) if coded.len() < generic.len() => coded, + _ => generic, + }) +} + /// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on /// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an /// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits @@ -62,69 +81,104 @@ pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result = vec![0; 1 << 16]; + let mut mq = MqEncoder::new(); + let height = samples.len() / stride; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let packed = samples.get(y * stride..).unwrap_or_default(); + for (x, pixel) in row.iter_mut().enumerate() { + let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + *pixel = u8::from(!sample); + } + }); + let region = mq.finish(); let mut out = Vec::with_capacity(region.len() + 64); - // Page information (7.4.8): the region's size, resolution unknown, eventually lossless, - // default pixel 0 (white), OR combination, not striped. + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, ®ion)); + Ok(out) +} + +/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually +/// lossless, default pixel 0 (white), OR combination, not striped. +fn page_information(columns: u32, rows: u32) -> Vec { let mut page = Vec::with_capacity(19); page.extend_from_slice(&columns.to_be_bytes()); page.extend_from_slice(&rows.to_be_bytes()); page.extend_from_slice(&[0; 8]); page.push(0x01); page.extend_from_slice(&[0, 0]); - segment(&mut out, 0, 48, &page); - // Immediate lossless generic region (7.4.6): region information (7.4.1) at (0, 0) with OR, - // flags (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then the data. - let mut generic = Vec::with_capacity(26 + region.len()); - generic.extend_from_slice(&columns.to_be_bytes()); - generic.extend_from_slice(&rows.to_be_bytes()); - generic.extend_from_slice(&[0; 8]); - generic.push(0); - generic.push(0x08); - for (x, y) in &TEMPLATE[12..] { - generic.push(*x as i8 as u8); - generic.push(*y as i8 as u8); - } - generic.extend_from_slice(®ion); - segment(&mut out, 1, 39, &generic); - Ok(out) + page } -/// A segment (7.2): its header (number, type, no referred-to segments, page 1, data length), then -/// its data. -fn segment(out: &mut Vec, number: u32, kind: u8, data: &[u8]) { +/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the +/// page, combined with it by OR. +fn region_information(out: &mut Vec, width: u32, height: u32, x: u32, y: u32) { + for v in [width, height, x, y] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(0); +} + +/// An immediate lossless generic region segment's data (7.4.6): its region information, flags +/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`. +fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec { + let mut data = Vec::with_capacity(26 + coded.len()); + region_information(&mut data, width, height, x, y); + data.push(0x08); + at_pixels(&mut data); + data.extend_from_slice(coded); + data +} + +/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2). +fn at_pixels(out: &mut Vec) { + for (x, y) in &TEMPLATE[12..] { + out.push(*x as i8 as u8); + out.push(*y as i8 as u8); + } +} + +/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), +/// then its data. `retained`: a later segment refers to this one. Segment numbers here are below +/// 256, so each referred-to one takes a byte (7.2.5); a segment refers to at most four, so their +/// count and retain bits take one byte too (7.2.4), and the retain bits of the referred-to ones +/// are 0: nothing after this segment refers to them. +fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[u8], retained: bool, data: &[u8]) { out.extend_from_slice(&number.to_be_bytes()); out.push(kind); - out.push(0); + let count = refers_to.len().min(4); + out.push((count as u8) << 5 | u8::from(retained)); + out.extend_from_slice(refers_to.get(..count).unwrap_or_default()); out.push(1); // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); out.extend_from_slice(data); } -/// The arithmetic-coded data of a generic region (6.2.5.7) with template 0, the nominal AT pixels -/// and typical prediction. -fn generic_region(samples: &[u8], width: usize, stride: usize) -> Vec { - let mut contexts: Vec = vec![0; 1 << 16]; - let mut mq = MqEncoder::new(); +/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal +/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical` +/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap +/// count as 0 (white), as the decoder takes them. +fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) { let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k); // The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded. let blank = vec![0u8; width + 2 * PAD]; let (mut above2, mut above1) = (blank.clone(), blank.clone()); let mut row = blank; let mut ltp = false; - for packed in samples.chunks_exact(stride) { - for (x, pixel) in row.iter_mut().skip(PAD).take(width).enumerate() { - let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); - *pixel = u8::from(!sample); + for y in 0..height { + if let Some(pixels) = row.get_mut(PAD..PAD + width) { + fill(y, pixels); } - // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. - let typical = row == above1; - if let Some(cx) = contexts.get_mut(sltp) { - mq.encode(cx, typical != ltp); + if typical { + // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. + let same = row == above1; + if let Some(cx) = contexts.get_mut(sltp) { + mq.encode(cx, same != ltp); + } + ltp = same; } - ltp = typical; - if !typical { + if !ltp { for x in 0..width { let at = |dx: i32, dy: i32| { let line = match dy { @@ -144,7 +198,6 @@ fn generic_region(samples: &[u8], width: usize, stride: usize) -> Vec { std::mem::swap(&mut above2, &mut above1); std::mem::swap(&mut above1, &mut row); } - mq.finish() } #[cfg(test)] @@ -153,7 +206,7 @@ mod tests { /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true /// for white (a JBIG2 0). - fn decode(data: &[u8]) -> Vec> { + pub(super) fn decode(data: &[u8]) -> Vec> { struct Rows(Vec>, Vec, usize); impl hayro_jbig2::Decoder for Rows { fn push_pixel(&mut self, black: bool) { @@ -175,7 +228,7 @@ mod tests { } /// The rows of packed samples `data`, true for a 1 bit. - fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { let stride = (columns as usize).div_ceil(8); (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() } @@ -242,7 +295,7 @@ mod tests { } /// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1. - fn samples(pixels: &[Vec]) -> Vec { + pub(super) fn samples(pixels: &[Vec]) -> Vec { let width = pixels.first().map_or(0, Vec::len); let stride = width.div_ceil(8); let mut data = vec![0xFF; stride * pixels.len()]; diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs new file mode 100644 index 0000000000..00aa187602 --- /dev/null +++ b/crates/filters/src/jbig2/symbols.rs @@ -0,0 +1,776 @@ +//! JBIG2 symbol coding, lossless: each distinct mark of an image (a group of black pixels that +//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and a +//! text region places a copy of it wherever it occurs. Marks too large to be symbols (rules, +//! frames, pictures) are left to a generic region beside it, and so, in [`encode`]'s second plan, +//! are marks that occur only once. Only exact copies share a symbol, so the image decodes to the +//! same bits. Written from the committee draft's 6.4 (the text region decoding procedure), 6.5 +//! (the symbol dictionary decoding procedure), 7.4.2 and 7.4.3 (their segments) and Annex A (the +//! arithmetic integer coders). + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::Range; + +use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment}; +use crate::mq::{Context, MqEncoder}; + +/// The longest side of a mark stored as a symbol. A larger one is left to the generic region. +const MAX_SYMBOL_SIDE: u32 = 256; + +/// The most black runs looked at. Past it (halftones, noise) the image is left to generic coding, +/// which suits it better anyway; this bounds the memory the marks take. +const MAX_RUNS: usize = 1 << 20; + +/// The most distinct symbols, and the most pixels their bitmaps may hold together. +const MAX_SYMBOLS: usize = 1 << 16; +const MAX_SYMBOL_PIXELS: usize = 1 << 24; + +/// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and +/// difference coded then fits the 32-bit integers Annex A codes. +const MAX_ROWS: u32 = 1 << 24; + +/// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the +/// offset into it, and its first value. The last range has no 0 after its prefix. +const RANGES: [(u32, u32, u32); 6] = [(0, 2, 0), (1, 4, 4), (2, 6, 20), (3, 8, 84), (4, 12, 340), (5, 32, 4436)]; + +/// A run of black pixels on row `y`, from `x0` up to but not including `x1`. +#[derive(Clone, Copy, Debug)] +struct Run { + y: u32, + x0: u32, + x1: u32, +} + +/// A mark: its box (`x1` and `y1` just past it) and its runs, as a range of the run order +/// [`marks`] gives. +#[derive(Debug)] +struct Mark { + x0: u32, + x1: u32, + y0: u32, + y1: u32, + runs: Range, +} + +/// A distinct mark's bitmap, a byte a pixel (1 black), and how many marks are exact copies of it. +#[derive(Debug)] +struct Symbol { + width: u32, + height: u32, + pixels: Vec, + uses: u32, +} + +/// An image's marks, sorted out: its black runs, its marks with the run order they index, the +/// distinct marks of at most [`MAX_SYMBOL_SIDE`] a side as symbols, each such mark as (its +/// symbol, the mark), and the marks too large to be symbols. +struct Page { + runs: Vec, + marks: Vec, + order: Vec, + symbols: Vec, + copies: Vec<(u32, u32)>, + large: Vec, +} + +/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes, as S (the column) +/// and T (the row). `width` is the symbol's. +#[derive(Clone, Copy, Debug)] +struct Instance { + id: u32, + s: u32, + t: u32, + width: u32, +} + +/// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, +/// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. +/// `None` when neither plan has two symbols, or the image is too large or too busy to look at. +pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { + let page = page(samples, columns, rows)?; + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) +} + +/// The image's marks and symbols; `None` past the limits above. +fn page(samples: &[u8], columns: u32, rows: u32) -> Option { + if rows > MAX_ROWS { + return None; + } + let width = usize::try_from(columns).ok()?; + let stride = width.div_ceil(8); + if stride == 0 { + return None; + } + let runs = black_runs(samples, width, stride)?; + let (marks, order) = marks(&runs); + let mut page = Page { runs, marks, order, symbols: Vec::new(), copies: Vec::new(), large: Vec::new() }; + symbols(&mut page)?; + Some(page) +} + +/// The black runs of each row (0 samples), row by row and left to right; `None` past [`MAX_RUNS`]. +fn black_runs(samples: &[u8], width: usize, stride: usize) -> Option> { + let black = |row: &[u8], x: usize| row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) == 0); + let mut runs = Vec::new(); + for (y, row) in samples.chunks_exact(stride).enumerate() { + let y = u32::try_from(y).ok()?; + let mut x = 0; + while x < width { + // Whole white bytes at once. + if x % 8 == 0 && row.get(x / 8) == Some(&0xFF) { + x += 8; + continue; + } + if !black(row, x) { + x += 1; + continue; + } + let start = x; + while x < width && black(row, x) { + x += 1; + } + if runs.len() == MAX_RUNS { + return None; + } + runs.push(Run { y, x0: u32::try_from(start).ok()?, x1: u32::try_from(x).ok()? }); + } + } + Some(runs) +} + +/// The marks the runs make, in the order their first runs come (so from the top), and the run +/// order: the runs' indexes, mark by mark, each mark's in row order. +fn marks(runs: &[Run]) -> (Vec, Vec) { + // Union–find over the runs; a set's root is its first run, as the larger root always joins + // the smaller. Runs on adjacent rows join when they overlap, counting a corner. + let mut parent: Vec = (0..runs.len()).map(|i| i as u32).collect(); + let (mut above, mut above_at, mut above_y): (&[Run], usize, Option) = (&[], 0, None); + let mut at = 0; + for row in runs.chunk_by(|a, b| a.y == b.y) { + let y = row.first().map(|r| r.y); + if above_y.and_then(|a| a.checked_add(1)) == y { + let mut i = 0; + for (j, b) in row.iter().enumerate() { + while above.get(i).is_some_and(|a| a.x1 < b.x0) { + i += 1; + } + let mut k = i; + while above.get(k).is_some_and(|a| a.x0 <= b.x1) { + union(&mut parent, above_at + k, at + j); + k += 1; + } + } + } + (above, above_at, above_y) = (row, at, y); + at += row.len(); + } + let mut mark_of = vec![0u32; runs.len()]; + let mut marks: Vec = Vec::new(); + let mut counts: Vec = Vec::new(); + for (i, r) in runs.iter().enumerate() { + let root = find_root(&mut parent, i); + let m = if root == i { + marks.push(Mark { x0: r.x0, x1: r.x1, y0: r.y, y1: r.y.saturating_add(1), runs: 0..0 }); + counts.push(0); + marks.len() - 1 + } else { + mark_of.get(root).map_or(0, |m| *m as usize) + }; + if let Some(slot) = mark_of.get_mut(i) { + *slot = m as u32; + } + if let (Some(mark), Some(count)) = (marks.get_mut(m), counts.get_mut(m)) { + mark.x0 = mark.x0.min(r.x0); + mark.x1 = mark.x1.max(r.x1); + mark.y1 = mark.y1.max(r.y.saturating_add(1)); + *count += 1; + } + } + let mut next = 0; + for (mark, count) in marks.iter_mut().zip(&counts) { + mark.runs = next..next + count; + next += count; + } + let mut order = vec![0u32; runs.len()]; + let mut fill: Vec = marks.iter().map(|m| m.runs.start).collect(); + for (i, m) in mark_of.iter().enumerate() { + if let Some(n) = fill.get_mut(*m as usize) { + if let Some(slot) = order.get_mut(*n) { + *slot = i as u32; + } + *n += 1; + } + } + (marks, order) +} + +/// The root of `i`'s set, halving the path to it as it goes. Parents are never above their +/// children, so the walk ends. +fn find_root(parent: &mut [u32], mut i: usize) -> usize { + loop { + let Some(&p) = parent.get(i) else { return i }; + let p = p as usize; + if p == i { + return i; + } + let grandparent = parent.get(p).copied().unwrap_or(p as u32); + if let Some(slot) = parent.get_mut(i) { + *slot = grandparent; + } + i = grandparent as usize; + } +} + +fn union(parent: &mut [u32], a: usize, b: usize) { + let (a, b) = (find_root(parent, a), find_root(parent, b)); + if let Some(slot) = parent.get_mut(a.max(b)) { + *slot = a.min(b) as u32; + } +} + +/// Each mark's own pixels in its box: those of its runs, not of other marks the box overlaps. +fn pixels(mark: &Mark, runs: &[Run], order: &[u32]) -> Vec { + // A mark's box holds its runs, so these don't go below 0; a symbol is at most 256 a side. + let width = mark.x1.saturating_sub(mark.x0) as usize; + let height = mark.y1.saturating_sub(mark.y0) as usize; + let mut pixels = vec![0u8; width * height]; + for r in order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| runs.get(*i as usize)) { + let row = r.y.saturating_sub(mark.y0) as usize * width; + let from = row + r.x0.saturating_sub(mark.x0) as usize; + let to = row + r.x1.saturating_sub(mark.x0) as usize; + pixels.get_mut(from..to).unwrap_or_default().fill(1); + } + pixels +} + +/// Sorts the marks into symbols: exact copies share one. `None` past [`MAX_SYMBOLS`] or +/// [`MAX_SYMBOL_PIXELS`]. +fn symbols(page: &mut Page) -> Option<()> { + let mut by_hash: HashMap> = HashMap::new(); + let mut kept = 0usize; + for (m, mark) in page.marks.iter().enumerate() { + let m = u32::try_from(m).ok()?; + let (width, height) = (mark.x1.saturating_sub(mark.x0), mark.y1.saturating_sub(mark.y0)); + if width > MAX_SYMBOL_SIDE || height > MAX_SYMBOL_SIDE { + page.large.push(m); + continue; + } + let pixels = pixels(mark, &page.runs, &page.order); + let mut hasher = DefaultHasher::new(); + (width, height, &pixels).hash(&mut hasher); + let same = by_hash.entry(hasher.finish()).or_default(); + let symbols = &mut page.symbols; + let known = + same.iter().copied().find(|s| symbols.get(*s as usize).is_some_and(|s| (s.width, s.height) == (width, height) && s.pixels == pixels)); + let id = match known { + Some(id) => id, + None => { + kept = kept.checked_add(pixels.len())?; + if kept > MAX_SYMBOL_PIXELS || symbols.len() == MAX_SYMBOLS { + return None; + } + let id = u32::try_from(symbols.len()).ok()?; + symbols.push(Symbol { width, height, pixels, uses: 0 }); + same.push(id); + id + } + }; + if let Some(s) = symbols.get_mut(id as usize) { + s.uses = s.uses.saturating_add(1); + } + page.copies.push((id, m)); + } + Some(()) +} + +/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, a text +/// region placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and +/// the other marks in a generic region. `None` with fewer than two such symbols: their IDs would +/// take no bits at all (SBSYMCODELEN 0), a corner of the standard left alone here. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { + let symbol = |s: u32| page.symbols.get(s as usize); + // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. + let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + if kept.len() < 2 { + return None; + } + kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); + let mut id = vec![None; page.symbols.len()]; + for (k, s) in kept.iter().enumerate() { + if let Some(slot) = id.get_mut(*s as usize) { + *slot = u32::try_from(k).ok(); + } + } + let mut instances = Vec::new(); + let mut left = page.large.clone(); + for (s, m) in &page.copies { + match (id.get(*s as usize).copied().flatten(), page.marks.get(*m as usize), symbol(*s)) { + (Some(id), Some(mark), Some(symbol)) => instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width }), + _ => left.push(*m), + } + } + let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); + let dictionary = symbol_dictionary(&kept)?; + let count = u32::try_from(kept.len()).ok()?; + let text = match log_strips { + Some(l) => text_region(&mut instances, count, columns, rows, l)?, + None => (0..4).filter_map(|l| text_region(&mut instances, count, columns, rows, l)).min_by_key(Vec::len)?, + }; + let mut out = Vec::new(); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 0, &[], true, &dictionary); + segment(&mut out, 2, 7, &[1], false, &text); + if !left.is_empty() { + segment(&mut out, 3, 39, &[], false, &leftover_region(&left, page)?); + } + Some(out) +} + +/// A symbol dictionary segment's data (7.4.2) defining `symbols` (in dictionary order) and +/// exporting them all: arithmetic coded, no refinement or aggregation, each bitmap a generic +/// region with template 0, its nominal AT pixels and no typical prediction (Table 16), the +/// bitmaps sharing one set of contexts. +fn symbol_dictionary(symbols: &[&Symbol]) -> Option> { + let count = u32::try_from(symbols.len()).ok()?; + let mut data = vec![0, 0]; + at_pixels(&mut data); + data.extend_from_slice(&count.to_be_bytes()); + data.extend_from_slice(&count.to_be_bytes()); + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + let (mut iadh, mut iadw, mut iaex) = (IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut height = 0; + for class in symbols.chunk_by(|a, b| a.height == b.height) { + let h = class.first()?.height; + iadh.encode(&mut mq, Some(int(i64::from(h) - i64::from(height))?)); + height = h; + let mut width = 0; + for s in class { + iadw.encode(&mut mq, Some(int(i64::from(s.width) - i64::from(width))?)); + width = s.width; + let w = s.width as usize; + code_generic(&mut mq, &mut contexts, w, s.height as usize, false, |y, row| { + let line = s.pixels.get(y * w..).unwrap_or_default(); + row.iter_mut().zip(line).for_each(|(p, v)| *p = *v); + }); + } + iadw.encode(&mut mq, None); + } + // The export flags (6.5.10): a run of no symbols not exported, then a run of all of them. + iaex.encode(&mut mq, Some(0)); + iaex.encode(&mut mq, Some(int(i64::from(count))?)); + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// An immediate lossless text region segment's data (7.4.3) placing `instances` over the whole +/// page: arithmetic coded, no refinement, strips of 2^`log_strips` rows, each symbol's +/// bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a line's letters share their T), +/// not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts `instances` into strips, each +/// left to right. +fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, rows: u32, log_strips: u32) -> Option> { + // LOGSBSTRIPS has two bits: strips of 1, 2, 4 or 8 rows. + if log_strips > 3 { + return None; + } + let strips = 1i64 << log_strips; + instances.sort_by_key(|i| (i.t >> log_strips, i.s)); + let mut data = Vec::new(); + region_information(&mut data, columns, rows, 0, 0); + data.extend_from_slice(&((log_strips as u16) << 2).to_be_bytes()); + data.extend_from_slice(&u32::try_from(instances.len()).ok()?.to_be_bytes()); + // SBSYMCODELEN (Table 31): ⌈log2 SBNUMSYMS⌉. + let length = symbols.checked_next_power_of_two()?.trailing_zeros(); + let mut ids: Vec = vec![0; 1 << length]; + let (mut iadt, mut iafs, mut iads, mut iait) = (IntCoder::new(), IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut mq = MqEncoder::new(); + // The initial STRIPT (6.4.5 step 2) is 0, so each strip's delta T is its first row over the + // strip size, from the strip before. + iadt.encode(&mut mq, Some(0)); + let (mut strip_t, mut first_s) = (0i64, 0i64); + for strip in instances.chunk_by(|a, b| a.t >> log_strips == b.t >> log_strips) { + let t = i64::from(strip.first()?.t >> log_strips << log_strips); + iadt.encode(&mut mq, Some(int((t - strip_t) / strips)?)); + strip_t = t; + let mut cur_s = 0; + for (k, instance) in strip.iter().enumerate() { + let s = i64::from(instance.s); + if k == 0 { + iafs.encode(&mut mq, Some(int(s - first_s)?)); + first_s = s; + } else { + iads.encode(&mut mq, Some(int(s - cur_s)?)); + } + if strips > 1 { + iait.encode(&mut mq, Some(int(i64::from(instance.t) - strip_t)?)); + } + encode_id(&mut mq, &mut ids, length, instance.id); + // The next S counts from this symbol's last column (6.4.5 step 3c x). + cur_s = s + i64::from(instance.width) - 1; + } + iads.encode(&mut mq, None); + } + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// A generic region segment's data holding the marks `left`, over their box. +fn leftover_region(left: &[u32], page: &Page) -> Option> { + let left: Vec<&Mark> = left.iter().filter_map(|m| page.marks.get(*m as usize)).collect(); + let x0 = left.iter().map(|m| m.x0).min()?; + let y0 = left.iter().map(|m| m.y0).min()?; + let x1 = left.iter().map(|m| m.x1).max()?; + let y1 = left.iter().map(|m| m.y1).max()?; + let (width, height) = (x1.saturating_sub(x0) as usize, y1.saturating_sub(y0) as usize); + let stride = width.div_ceil(8); + // Packed, 1 for black: no larger than the image's own samples. + let mut packed = vec![0u8; stride.checked_mul(height)?]; + for mark in &left { + for r in page.order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| page.runs.get(*i as usize)) { + let row = r.y.saturating_sub(y0) as usize * stride; + for x in r.x0.saturating_sub(x0) as usize..r.x1.saturating_sub(x0) as usize { + if let Some(b) = packed.get_mut(row + x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + } + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let line = packed.get(y * stride..).unwrap_or_default(); + for (x, p) in row.iter_mut().enumerate() { + *p = u8::from(line.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0)); + } + }); + Some(generic_region(u32::try_from(width).ok()?, u32::try_from(height).ok()?, x0, y0, &mq.finish())) +} + +fn int(v: i64) -> Option { + i32::try_from(v).ok() +} + +/// An arithmetic integer coder other than IAID (A.2): its 512 contexts, indexed by PREV. +struct IntCoder([Context; 512]); + +impl IntCoder { + fn new() -> Self { + IntCoder([0; 512]) + } + + /// Codes `value`, or OOB for `None`. + fn encode(&mut self, mq: &mut MqEncoder, value: Option) { + for (cx, d) in decisions(value) { + if let Some(state) = self.0.get_mut(cx) { + mq.encode(state, d); + } + } + } +} + +/// The decisions that code `value`, or OOB for `None`, with their contexts (A.2, Table A.1): the +/// sign, the prefix that picks the range, then the offset into it, most significant bit first; +/// OOB is a negative 0. Each decision's context is PREV: a leading 1 and the decisions before it, +/// at most the last eight once there are more. +fn decisions(value: Option) -> Vec<(usize, bool)> { + let (negative, magnitude) = value.map_or((true, 0), |v| (v < 0, v.unsigned_abs())); + let (ones, bits, first) = + RANGES.into_iter().find(|(_, bits, first)| *bits == 32 || u64::from(magnitude) < u64::from(*first) + (1 << bits)).unwrap_or(RANGES[5]); + let offset = magnitude - first; + let mut d = vec![negative]; + d.extend(std::iter::repeat_n(true, ones as usize)); + if ones < 5 { + d.push(false); + } + d.extend((0..bits).rev().map(|i| offset >> i & 1 == 1)); + let mut prev = 1usize; + d.into_iter() + .map(|bit| { + let cx = prev; + prev = if prev < 256 { prev << 1 | usize::from(bit) } else { ((prev << 1 | usize::from(bit)) & 511) | 256 }; + (cx, bit) + }) + .collect() +} + +/// Codes symbol ID `id` in `length` bits, most significant first, through IAID (A.3): each +/// decision's context is a leading 1 and all the bits before it. +fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) { + let mut prev = 1usize; + for i in (0..length).rev() { + let d = id >> i & 1 == 1; + if let Some(state) = contexts.get_mut(prev) { + mq.encode(state, d); + } + prev = prev << 1 | usize::from(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jbig2::tests::{decode, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + + type Bitmap = Vec>; + + /// A small linear congruential generator, so the pictures are the same every run. + struct Lcg(u64); + + impl Lcg { + fn below(&mut self, n: usize) -> usize { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as usize % n.max(1) + } + } + + /// `count` different letter-like shapes: three strokes two pixels thick in a box of 6 to 18 + /// pixels a side, each after the first starting on one before it, so a shape is one mark; + /// trimmed to its pixels. + fn glyphs(count: usize, seed: u64) -> Vec { + let mut r = Lcg(seed); + let mut out: Vec = Vec::new(); + while out.len() < count { + let (w, h) = (6 + r.below(13), 6 + r.below(13)); + let mut g = vec![vec![0u8; w]; h]; + let (mut x, mut y) = (r.below(w), r.below(h)); + for _ in 0..3 { + let (dx, dy) = [(1, 0), (0, 1), (1, 1)][r.below(3)]; + let mut stroke = Vec::new(); + while x < w && y < h { + g[y][x] = 1; + g[y][(x + 1).min(w - 1)] = 1; + stroke.push((x, y)); + x += dx; + y += dy; + } + (x, y) = stroke[r.below(stroke.len())]; + } + let rows: Vec = (0..h).filter(|y| g[*y].contains(&1)).collect(); + let columns: Vec = (0..w).filter(|x| g.iter().any(|row| row[*x] == 1)).collect(); + let g: Bitmap = g[rows[0]..=rows[rows.len() - 1]].iter().map(|row| row[columns[0]..=columns[columns.len() - 1]].to_vec()).collect(); + if !out.contains(&g) { + out.push(g); + } + } + out + } + + /// Draws `glyph` with its top-left pixel at (`x`, `y`), clipped to the page, by OR. + fn draw(page: &mut Bitmap, glyph: &Bitmap, x: i64, y: i64) { + for (gy, row) in glyph.iter().enumerate() { + for (gx, p) in row.iter().enumerate() { + let (px, py) = (x + gx as i64, y + gy as i64); + if *p == 1 + && px >= 0 + && py >= 0 + && let Some(cell) = page.get_mut(py as usize).and_then(|r| r.get_mut(px as usize)) + { + *cell = 1; + } + } + } + } + + /// Lines of text: glyphs side by side, one to four pixels apart, each line's glyphs on its + /// baseline, the lines 30 rows apart; the first glyphs are the most common, as letters are. + fn text(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for baseline in (24..rows).step_by(30) { + let mut x = 4 + r.below(8); + loop { + let common = r.below(glyphs.len()) + 1; + let g = &glyphs[r.below(common)]; + if x + g[0].len() > columns { + break; + } + draw(&mut page, g, x as i64, (baseline + 1 - g.len()) as i64); + x += g[0].len() + 1 + r.below(4); + } + } + page + } + + /// `n` glyphs anywhere, some over the edges, some over each other. + fn scatter(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64, n: usize) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for _ in 0..n { + let g = &glyphs[r.below(glyphs.len())]; + draw(&mut page, g, r.below(columns + 12) as i64 - 6, r.below(rows + 12) as i64 - 6); + } + page + } + + /// The segments of a stream in the embedded organisation, from their headers (7.2): each one's + /// type, whether it is retained, and the segments it refers to. + fn segments(data: &[u8]) -> Vec<(u8, bool, Vec)> { + let (mut segments, mut at) = (Vec::new(), 0); + while at < data.len() { + let refers = usize::from(data[at + 5] >> 5); + let length_at = at + 6 + refers + 1; + let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + segments.push((data[at + 4] & 0x3F, data[at + 5] & 1 == 1, data[at + 6..at + 6 + refers].to_vec())); + at = length_at + 4 + length; + } + segments + } + + fn segment_types(data: &[u8]) -> Vec { + segments(data).into_iter().map(|(kind, ..)| kind).collect() + } + + /// Every plan, at every strip size, decodes to the picture; returns how many plans there were. + fn every_plan_round_trips(picture: &Bitmap) -> usize { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(picture); + let page = page(&data, columns, rows).unwrap(); + let mut plans = 0; + for copies in [1, 2] { + for log_strips in 0..4 { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips)) { + assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); + plans += 1; + } + } + } + plans + } + + /// Annex A.2's example (12 through IADW: the decisions 0101000, in contexts 1, 10, 101, …) + /// and Table A.1's codes. + #[test] + fn integers_are_coded_as_annex_a_says() { + assert_eq!(decisions(Some(12)), [(1, false), (2, true), (5, false), (10, true), (21, false), (42, false), (84, false)]); + let bits = |v| decisions(v).iter().map(|(_, d)| u8::from(*d)).collect::>(); + assert_eq!(bits(None), [1, 0, 0, 0], "OOB"); + assert_eq!(bits(Some(0)), [0, 0, 0, 0]); + assert_eq!(bits(Some(-1)), [1, 0, 0, 1]); + assert_eq!(bits(Some(-3)), [1, 0, 1, 1]); + assert_eq!(bits(Some(4)), [0, 1, 0, 0, 0, 0, 0]); + assert_eq!(bits(Some(-19)), [1, 1, 0, 1, 1, 1, 1]); + assert_eq!(bits(Some(83)), [0, 1, 1, 0, 1, 1, 1, 1, 1, 1]); + assert_eq!(bits(Some(4435))[..6], [0, 1, 1, 1, 1, 0]); + let big = bits(Some(4436)); + assert_eq!((big.len(), &big[..6]), (6 + 32, &[0, 1, 1, 1, 1, 1][..])); + assert!(big[6..].iter().all(|b| *b == 0)); + // PREV keeps a leading 1 and the last eight decisions. + let contexts: Vec = decisions(Some(i32::MIN)).iter().map(|(cx, _)| *cx).collect(); + assert!(contexts[..8].iter().all(|cx| *cx < 256) && contexts[8..].iter().all(|cx| (256..512).contains(cx))); + } + + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: + /// a symbol dictionary and a text region, much smaller than the generic coding. + #[test] + fn repeated_marks_are_stored_once() { + let picture = text(1700, 400, &glyphs(40, 7), 11); + let data = samples(&picture); + let coded = encode_jbig2(&data, 1700, 400).unwrap(); + let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); + // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). + assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), (7, false, vec![1])]); + assert_eq!(decode(&coded), unpack(&data, 1700, 400)); + assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks too large to be symbols (a frame, a rule) go to a generic region over their box, and + /// with the second plan so do marks that occur once; the page still decodes to its bits. + #[test] + fn large_and_lone_marks_are_left_to_a_generic_region() { + let mut picture = text(900, 300, &glyphs(25, 3), 5); + picture[10][100..800].fill(1); + picture[290][100..800].fill(1); + for row in &mut picture[10..=290] { + row[100] = 1; + row[799] = 1; + } + // A lone blob, unlike any letter. + for (y, row) in picture[150..170].iter_mut().enumerate() { + row[850 + y % 7] = 1; + } + let data = samples(&picture); + let coded = encode_jbig2(&data, 900, 300).unwrap(); + assert_eq!(segment_types(&coded), [48, 0, 7, 39]); + assert_eq!(decode(&coded), unpack(&data, 900, 300)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks on every edge, marks touching only at a corner (one mark), a mark inside another's + /// box, and marks over each other's boxes. + #[test] + fn marks_at_the_edges_and_corners_round_trip() { + let dot = vec![vec![1u8, 1], vec![1, 1]]; + let ell = vec![vec![1u8, 0, 0], vec![1, 0, 0], vec![1, 1, 1]]; + let mut picture = vec![vec![0u8; 41]; 23]; + for (x, y) in [(0, 0), (39, 0), (0, 21), (39, 21), (20, 0), (0, 10), (39, 10), (20, 21)] { + draw(&mut picture, &dot, x, y); + } + for (x, y) in [(10, 5), (28, 14), (12, 14)] { + draw(&mut picture, &ell, x, y); + } + // Corners touching: one mark. + picture[3][6] = 1; + picture[4][7] = 1; + picture[5][8] = 1; + // A ring with a dot inside, and an ell over the ring's box. + for i in 0..7 { + picture[8][20 + i] = 1; + picture[14][20 + i] = 1; + picture[8 + i][20] = 1; + picture[8 + i][26] = 1; + } + draw(&mut picture, &dot, 22, 10); + draw(&mut picture, &ell, 25, 15); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// One shape, however often, would need symbol IDs of no bits: that image is coded as a generic + /// region, as are a blank page and pure noise. + #[test] + fn one_shape_blank_pages_and_noise_are_coded_generically() { + let letter = &glyphs(1, 9)[..]; + let mut r = Lcg(1); + let noise: Bitmap = (0..60).map(|_| (0..200).map(|_| u8::from(r.below(2) == 1)).collect()).collect(); + for picture in [text(600, 200, letter, 2), vec![vec![0u8; 300]; 40], noise] { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(&picture); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + } + } + + proptest::proptest! { + /// Glyphs anywhere, over the edges and each other, with stray pixels: every plan decodes to + /// the picture, and `encode_jbig2` is never larger than the generic coding. + #[test] + fn scattered_marks_round_trip( + columns in 1usize..240, + rows in 1usize..120, + shapes in 2usize..10, + n in 0usize..80, + seed in proptest::num::u64::ANY, + stray in proptest::collection::vec((0usize..240, 0usize..120), 0..20), + ) { + let mut picture = scatter(columns, rows, &glyphs(shapes, seed), seed, n); + for (x, y) in stray { + if let Some(cell) = picture.get_mut(y).and_then(|r| r.get_mut(x)) { + *cell ^= 1; + } + } + every_plan_round_trips(&picture); + let data = samples(&picture); + let (c, r) = (columns as u32, rows as u32); + let coded = encode_jbig2(&data, c, r).unwrap(); + proptest::prop_assert!(coded.len() <= encode_jbig2_generic(&data, c, r).unwrap().len()); + proptest::prop_assert_eq!(decode(&coded), unpack(&data, c, r)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2(&data, columns, rows); + let _ = encode(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index da675a024b..30094e6524 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -7,7 +7,7 @@ //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so //! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 -//! ([`encode_g4`]). +//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -27,7 +27,7 @@ mod runlength; use std::borrow::Cow; pub use ccitt::{MAX_G4_COLUMNS, encode_g4}; -pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2_generic}; +pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] diff --git a/crates/optimize/README.md b/crates/optimize/README.md index a1c47697b5..4c8ae1cd3e 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -37,12 +37,15 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going - **Monochrome images** (`Settings::mono`, CCITT Group 4 by default, or JBIG2): a 1-bit image with one colour component (grey, indexed, a separation) or an image mask, stored with no filter or with general-purpose ones, is stored again as CCITT Group 4 (`pdfcraft_filters::encode_g4`) - or lossless JBIG2 (`encode_jbig2_generic`; a document below PDF 1.4 gets `/Version /1.4` in - its catalog) when that is smaller as stored. On 300 dpi text JBIG2 is about a third smaller - than Group 4. It is lossless: the samples decode to the same bits, so the colour + or lossless JBIG2 (`encode_jbig2`; a document below PDF 1.4 gets `/Version /1.4` in + its catalog) when that is smaller as stored. JBIG2 stores each distinct mark once, as a + symbol, when the image's marks repeat exactly, as rendered text's do: on six pages of + 300 dpi text that came to 28% of a generic region's size and 17% of Group 4's. On scans, whose copies of a letter are seldom identical, it is a generic region, + about a third smaller than Group 4. It is lossless: the samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: - JBIG2 symbol coding (lossy or lossless), monochrome downsampling, inline images, soft masks and + JBIG2 refinement coding (lossless gains on scans), a symbol dictionary shared between images + (`/JBIG2Globals`), lossy JBIG2, monochrome downsampling, inline images, soft masks and `/Mask` images, and Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index cf668d0fbf..392e9145ee 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -596,9 +596,10 @@ fn mono(doc: &Document, s: &Stream, compression: MonoCompression) -> Option Vec { + const LETTERS: [&[&str]; 5] = [ + &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], + &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], + &["##....", "##....", "##....", "#####.", "##..##", "##..##", "##..##", "#####."], + &["##..##", "##..##", "##..##", "##..##", ".#####"], + &["##", "##", "..", "##", "##", "##", "##", "##"], + ]; + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for baseline in (32..h as usize).step_by(48) { + let mut x = 8; + while x + 20 < w as usize { + let letter = LETTERS[(next() % 5) as usize]; + for (dy, row) in letter.iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + for (py, px) in (0..9).map(|k| (baseline + 1 + 3 * dy + k / 3 - 3 * letter.len(), x + 3 * dx + k % 3)) { + if c == b'#' { + data[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + } + x += 3 * letter[0].len() + 3 + if next() % 5 == 0 { 12 } else { 0 }; + } + } + data +} + +/// Text whose letters repeat exactly, as rendered text does, becomes JBIG2 symbols: each letter +/// stored once and its copies placed, several times smaller than JBIG2's generic coding of the +/// same image, and the page looks the same. +#[test] +fn rendered_text_becomes_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (600, 480); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // One image pixel a device pixel at scale 1, so a wrong pixel shows. + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + let before = render(&doc); + let r = optimize(&mut doc, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap(); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len()); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index a1c26eefb4..68d26bcce4 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: lossless JBIG2 encoding in the Optimizer; JBIG2 images export; earlier, §7.4: CCITT Group 4 encoding in the Optimizer) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (§7.4: JBIG2 symbol coding in the Optimizer; earlier, lossless JBIG2 encoding, JBIG2 images export and CCITT Group 4 encoding) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -21,7 +21,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 |---|---|---|---|---| | §7.2–7.5 | Syntax, objects, file structure, xref tables and streams, object streams, hybrid files, incremental updates | full | **own**, tolerant, repairs damaged files | `crates/cos`; corpus 958 files; fuzzed nightly | | §7.4 | Stream filters: Flate, LZW, ASCII85, ASCIIHex, RunLength, predictors | full | **own** | `crates/filters` | -| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 encoding; **own** JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; the Optimizer encodes CCITT Group 4 (`pdfcraft_filters::encode_g4`) and lossless JBIG2 (a generic region; no symbol coding), `optimize.mono-compression` | +| §7.4 | Image filters: DCT, JPX, JBIG2, CCITTFax | full | **borrowed** (decode); CCITT Group 4 encoding; **own** JBIG2 encoding | `core.image-filters` partial; own codecs M1.1/M1.2; the Optimizer encodes CCITT Group 4 (`pdfcraft_filters::encode_g4`) and lossless JBIG2 (a generic region, or symbols for exact copies; no refinement, `/JBIG2Globals` or lossy coding), `optimize.mono-compression` | | §7.5.8 | Linearization | R W | R only | `core.linearization` planned | | §7.6.4 | Standard security handler R2–R6 (RC4 40/128, AES-128, AES-256), crypt filters, `/EncryptMetadata`, SASLprep | full | **own**, read and write | `crates/crypt`; written files open in Acrobat DC since #836 (#774: Acrobat needs the catalog outside object streams) | | §7.6.5 | Public-key security handler | full | none | `core.open-public-key`, `protect.certificate-encryption` planned | @@ -130,6 +130,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | §7.4: lossless JBIG2 symbol coding (symbol dictionaries and text regions, own, from the JBIG2 draft standard) in the Optimizer, used when smaller than the generic region | | 2026-10-26 | minor | §7.4: lossless JBIG2 generic-region encoding (own, from the JBIG2 draft standard) in the Optimizer; Export ▸ Images decodes JBIG2 | | 2026-10-26 | minor | §7.4: CCITT Group 4 encoding (`fax`) in the Optimizer | | 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f376cfb7f..804af97178 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2, and Export ▸ Images reads JBIG2 images; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -257,6 +258,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | M11: lossless JBIG2 symbol coding (`pdfcraft_filters::encode_jbig2`): each distinct mark stored once in a symbol dictionary and its exact copies placed by a text region, larger and lone marks in a generic region, used when smaller than the generic coding; on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's. Scans gain little until refinement coding; `/JBIG2Globals` and lossy coding not yet. Estimates unchanged. | | 2026-10-23 | minor | M11: lossless JBIG2 for black-and-white images (`pdfcraft_filters::encode_jbig2_generic`, a generic region coded with the MQ coder, written from the JBIG2 draft standard), as Monochrome Images ▸ JBIG2 (lossless) and `doc_optimize` `mono: jbig2`; about a third smaller than Group 4 on 300 dpi text. Export ▸ Images reads JBIG2 images (`hayro-jbig2`, with `/JBIG2Globals`). Symbol coding not yet. Estimates unchanged. | | 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7bdf89ecad..c18856e3aa 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7384,8 +7384,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] -evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 is a generic region (template 0, typical prediction), about a third smaller than Group 4 on 300 dpi text; a document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 symbol coding (text regions with symbol dictionaries, where JBIG2's larger gains on text come from, and its lossy mode), JBIG2Globals, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, the generic region, about a third smaller than Group 4. A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From 0d40594cf9ff0ca4b421d8816257a7ec7860a28a Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 11:05:07 +0100 Subject: [PATCH 76/82] M11: JBIG2 symbol coding: tests for padding, the 32-bit range and the mark rules Review found behaviour the tests let break unnoticed (each was a surviving mutant, and each new test fails on it): - black padding bits: a run reaching past the row would stop symbol coding on many scans; - Annex A's 32-bit range and PREV at exactly 256, where a decision would go uncoded: coordinates past 4,435 on a 12,000-pixel-wide and a 10,050-row page, and the contexts of 4436's coding; - marks joined at a corner are one mark; 256 pixels a side is a symbol, 257 is left to the generic region; - the run and row caps, at and past them. The roadmap's status line for this change now comes first, and the "about a third smaller than Group 4 on scans" figure is replaced by what was measured: 27-33% on the same pages with scan-like noise added. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/filters/README.md | 2 +- crates/filters/src/jbig2/symbols.rs | 94 ++++++++++++++++++++++++++++- crates/optimize/README.md | 10 +-- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 2 +- 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index 0113e22179..5df0761eb9 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -54,7 +54,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner are one mark, and 256 pixels a side is a symbol while 257 is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs or 2^24 rows the image is coded generically; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index 00aa187602..48013e9864 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -655,9 +655,97 @@ mod tests { let big = bits(Some(4436)); assert_eq!((big.len(), &big[..6]), (6 + 32, &[0, 1, 1, 1, 1, 1][..])); assert!(big[6..].iter().all(|b| *b == 0)); - // PREV keeps a leading 1 and the last eight decisions. - let contexts: Vec = decisions(Some(i32::MIN)).iter().map(|(cx, _)| *cx).collect(); - assert!(contexts[..8].iter().all(|cx| *cx < 256) && contexts[8..].iter().all(|cx| (256..512).contains(cx))); + // PREV keeps a leading 1 and the last eight decisions, so it stays below 512, also when + // those eight are all 0 (PREV exactly 256), as in 4436's offset of 0. + for value in [Some(i32::MIN), Some(4436), Some(-4436), Some(4436 + 0x100), Some(i32::MAX), None] { + let contexts: Vec = decisions(value).iter().map(|(cx, _)| *cx).collect(); + assert!(contexts.iter().take(8).all(|cx| *cx < 256) && contexts.iter().skip(8).all(|cx| (256..512).contains(cx)), "{value:?}"); + } + assert!(decisions(Some(4436)).iter().any(|(cx, _)| *cx == 256)); + } + + /// Strips and first columns thousands of pixels apart need Annex A's 32-bit range: a mark at + /// the far right of a 12,000-pixel page, then one at the left below it (S back by 11,000), and + /// lines 5,000 rows apart. Each shape occurs twice, so both plans are made. + #[test] + fn coordinates_past_4435_round_trip() { + let shapes = glyphs(3, 4); + let mut picture = vec![vec![0u8; 12_000]; 40]; + for (k, x) in [11_000, 0, 5_000, 11_980].into_iter().enumerate() { + draw(&mut picture, &shapes[k % 2], x, 2 + 9 * k as i64); + } + assert_eq!(every_plan_round_trips(&picture), 8); + let mut tall = vec![vec![0u8; 30]; 10_050]; + for (k, y) in [0, 5_000, 10_030].into_iter().enumerate() { + draw(&mut tall, &shapes[k % 3], 3, y); + draw(&mut tall, &shapes[(k + 1) % 3], 15, y); + } + assert_eq!(every_plan_round_trips(&tall), 8); + } + + /// The padding bits of a row whose width isn't a multiple of 8 are ignored whatever they are: + /// black (0) padding, as many scans have, neither joins the marks nor stops symbol coding. + #[test] + fn black_padding_bits_are_ignored() { + for columns in [13, 241, 901] { + let picture = text(columns, 120, &glyphs(12, 6), 8); + let mut data = samples(&picture); + let stride = columns.div_ceil(8); + for row in data.chunks_mut(stride) { + row[stride - 1] &= 0xFF << (8 * stride - columns); + } + let (c, r) = (columns as u32, 120); + let page = page(&data, c, r).unwrap(); + assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); + for copies in [1, 2] { + if let Some(coded) = plan(&page, c, r, copies, None) { + assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); + } + } + if columns > 13 { + assert_eq!(segment_types(&encode_jbig2(&data, c, r).unwrap())[1], 0, "{columns} columns: symbol coded"); + } + } + } + + /// Pixels touching only at a corner are one mark (8-connected); side by side with a white + /// column between, two. A mark of 256 pixels a side is a symbol, one of 257 is left to the + /// generic region. The diagonal and the dot occur twice, so both plans are made. + #[test] + fn marks_join_at_corners_and_large_ones_are_left_out() { + let mut picture = vec![vec![0u8; 300]; 300]; + for i in 0..6 { + picture[10 + i][10 + i] = 1; + picture[10 + i][100 + i] = 1; + } + picture[30][10] = 1; + picture[30][12] = 1; + picture[40][0..256].fill(1); + picture[60][0..257].fill(1); + for row in &mut picture[44..300] { + row[290] = 1; + } + let data = samples(&picture); + let page = page(&data, 300, 300).unwrap(); + let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); + assert_eq!(boxes, [(10, 10, 6, 6), (100, 10, 6, 6), (10, 30, 1, 1), (12, 30, 1, 1), (0, 40, 256, 1), (290, 44, 1, 256), (0, 60, 257, 1)]); + let large: Vec<(u32, u32)> = page.large.iter().map(|m| (page.marks[*m as usize].x0, page.marks[*m as usize].y0)).collect(); + assert_eq!(large, [(0, 60)]); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at 2^20 runs it is + /// still looked at. + #[test] + fn busy_or_tall_images_are_left_to_generic_coding() { + // 0x55: four black pixels a byte, each its own run, under the same ones on the next row. + let columns = 1 << 20; + let row = vec![0x55u8; columns / 8]; + assert!(page(&row.repeat(2), columns as u32, 2).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32, 3).is_none(), "1.5 × 2^20 runs"); + let tall = vec![0xFFu8; MAX_ROWS as usize + 1]; + assert!(page(&tall, 1, MAX_ROWS + 1).is_none()); + assert!(page(&tall, 1, MAX_ROWS).is_some()); } /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 4c8ae1cd3e..97915f8d8c 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -40,10 +40,12 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going or lossless JBIG2 (`encode_jbig2`; a document below PDF 1.4 gets `/Version /1.4` in its catalog) when that is smaller as stored. JBIG2 stores each distinct mark once, as a symbol, when the image's marks repeat exactly, as rendered text's do: on six pages of - 300 dpi text that came to 28% of a generic region's size and 17% of Group 4's. On scans, whose copies of a letter are seldom identical, it is a generic region, - about a third smaller than Group 4. It is lossless: the samples decode to the same bits, so the colour - space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; - on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: + 300 dpi text that came to 28% of a generic region's size and 17% of Group 4's. On scans, + whose copies of a letter are seldom identical, it is nearly always a generic region: 27–33% + smaller than Group 4 on the same pages with scan-like noise added. It is lossless: the + samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 + wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can + be smaller, and the image then stays. Not yet: JBIG2 refinement coding (lossless gains on scans), a symbol dictionary shared between images (`/JBIG2Globals`), lossy JBIG2, monochrome downsampling, inline images, soft masks and `/Mask` images, and Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to diff --git a/docs/roadmap.md b/docs/roadmap.md index 804af97178..f21943c38f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,8 @@ # PdfCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) -> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-24 · **Change:** minor (M11: the PDF Optimizer can store black-and-white images as lossless JBIG2, and Export ▸ Images reads JBIG2 images; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: the PDF Optimizer and Reduce File Size store black-and-white images as CCITT Group 4 when smaller; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c18856e3aa..e801495fde 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, the generic region, about a third smaller than Group 4. A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, nearly always the generic region (27–33% smaller than Group 4 on the same pages with scan-like noise added). A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From 8eeafe330975a65b524c6a6a20d5a7fd852aa27a Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 11:22:15 +0100 Subject: [PATCH 77/82] M11: JBIG2 symbol coding: tests for tall marks, the other corner and the symbol caps A second review found two rules tested one way only: a mark 257 tall (not just wide) is left to the generic region, which also bounds the bitmap allocated for each mark, and runs joined at the lower-left corner are one mark. The test now has a 1 x 257 column and an anti-diagonal. The caps on distinct symbols (65,536) and their pixels (2^24) are tested at and past them, as the run and row caps are. The scans figure now names its noise: with 2% or more of the letters' edge pixels flipped, symbols save at most 8% over the generic region. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/filters/README.md | 2 +- crates/filters/src/jbig2/symbols.rs | 73 ++++++++++++++++++++++++++--- crates/optimize/README.md | 5 +- parity/acrobat-features.toml | 2 +- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index 5df0761eb9..cf8ebb6558 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -54,7 +54,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner are one mark, and 256 pixels a side is a symbol while 257 is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs or 2^24 rows the image is coded generically; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 symbols or 2^24 symbol pixels the image is coded generically, and at each cap it is not; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index 48013e9864..e4c79bc585 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -708,15 +708,20 @@ mod tests { } } - /// Pixels touching only at a corner are one mark (8-connected); side by side with a white - /// column between, two. A mark of 256 pixels a side is a symbol, one of 257 is left to the - /// generic region. The diagonal and the dot occur twice, so both plans are made. + /// Pixels touching only at a corner, down to the right or down to the left, are one mark + /// (8-connected); side by side with a white column between, two. A mark of 256 pixels a side + /// is a symbol; one 257 wide, or 257 tall, is left to the generic region. The diagonal and the + /// dot occur twice, so both plans are made. #[test] fn marks_join_at_corners_and_large_ones_are_left_out() { let mut picture = vec![vec![0u8; 300]; 300]; for i in 0..6 { picture[10 + i][10 + i] = 1; picture[10 + i][100 + i] = 1; + picture[10 + i][60 - i] = 1; + } + for row in &mut picture[0..257] { + row[280] = 1; } picture[30][10] = 1; picture[30][12] = 1; @@ -728,9 +733,22 @@ mod tests { let data = samples(&picture); let page = page(&data, 300, 300).unwrap(); let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); - assert_eq!(boxes, [(10, 10, 6, 6), (100, 10, 6, 6), (10, 30, 1, 1), (12, 30, 1, 1), (0, 40, 256, 1), (290, 44, 1, 256), (0, 60, 257, 1)]); + assert_eq!( + boxes, + [ + (280, 0, 1, 257), + (10, 10, 6, 6), + (55, 10, 6, 6), + (100, 10, 6, 6), + (10, 30, 1, 1), + (12, 30, 1, 1), + (0, 40, 256, 1), + (290, 44, 1, 256), + (0, 60, 257, 1) + ] + ); let large: Vec<(u32, u32)> = page.large.iter().map(|m| (page.marks[*m as usize].x0, page.marks[*m as usize].y0)).collect(); - assert_eq!(large, [(0, 60)]); + assert_eq!(large, [(280, 0), (0, 60)]); assert_eq!(every_plan_round_trips(&picture), 8); } @@ -748,6 +766,49 @@ mod tests { assert!(page(&tall, 1, MAX_ROWS).is_some()); } + /// Past 65,536 distinct symbols, or 2^24 pixels of them, the image is left to generic coding; + /// at either cap it is still looked at. + #[test] + fn too_many_symbols_are_left_to_generic_coding() { + // `n` distinct marks of 17 × 3: full top and bottom rows joined by a code of 1 to n, in + // binary, on the row between. + let codes = |n: usize| { + let (columns, rows) = (256 * 18, n.div_ceil(256) * 4); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n { + let (x, y) = (k % 256 * 18, k / 256 * 4); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = codes(MAX_SYMBOLS); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, rows) = codes(MAX_SYMBOLS + 1); + assert!(page(&data, columns, rows).is_none()); + // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 + // pixels a symbol, so 256 of them are exactly the cap. + let squares = |n: usize| { + let (columns, rows) = (16 * 258, n.div_ceil(16) * 258); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n { + let (x, y) = (k % 16 * 258, k / 16 * 258); + for row in &mut picture[y..y + 256] { + row[x..x + 256].fill(1); + } + picture[y + 1 + k / 254][x + 1 + k % 254] = 0; + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = squares(256); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(256)); + let (data, columns, rows) = squares(257); + assert!(page(&data, columns, rows).is_none()); + } + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: /// a symbol dictionary and a text region, much smaller than the generic coding. #[test] @@ -798,7 +859,7 @@ mod tests { for (x, y) in [(10, 5), (28, 14), (12, 14)] { draw(&mut picture, &ell, x, y); } - // Corners touching: one mark. + // Corners touching: one mark (`marks_join_at_corners_and_large_ones_are_left_out` checks it). picture[3][6] = 1; picture[4][7] = 1; picture[5][8] = 1; diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 97915f8d8c..8648e39d86 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -41,8 +41,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going its catalog) when that is smaller as stored. JBIG2 stores each distinct mark once, as a symbol, when the image's marks repeat exactly, as rendered text's do: on six pages of 300 dpi text that came to 28% of a generic region's size and 17% of Group 4's. On scans, - whose copies of a letter are seldom identical, it is nearly always a generic region: 27–33% - smaller than Group 4 on the same pages with scan-like noise added. It is lossless: the + whose copies of a letter are seldom identical, symbols gain little: on the same pages with 2% + or more of the letters' edge pixels flipped at random, at most 8% below a generic region, + which was 27–33% below Group 4. It is lossless: the samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e801495fde..2a83a68edf 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, nearly always the generic region (27–33% smaller than Group 4 on the same pages with scan-like noise added). A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, little: on the same pages with 2% or more of the letters' edge pixels flipped at random, at most 8% below the generic region, which was 27–33% below Group 4. A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From deb70c92cadadbaa474a13518299aabb73d9ff82 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 11:45:31 +0100 Subject: [PATCH 78/82] M11: JBIG2 symbol coding: at most 10,000 copies a text region and 32,767 symbols A review found that a dense page came out as a stream PdfCraft's own renderer refuses, so the page drew blank after optimizing: hayro-jbig2 decodes a text region of at most 10,000 symbol instances, and refuses 32,768 symbols or more. The stream was valid (Poppler decoded it), but every mark went into one text region and up to 65,536 symbols were kept. A 300 dpi letter page of the optimizer test's own text crossed it. A page with more than 10,000 marks is now placed by several text regions, each over a band of rows and all referring to the one symbol dictionary, which each region but the last keeps retained (7.2.4). Past 32,767 distinct symbols the image is coded generically. New tests: 25,000 copies placed by three regions, which decode; the symbol caps decoded, not just reached; and in the optimizer, a whole letter page of rendered text that looks the same as JBIG2 symbols (it drew blank before). The READMEs say "still looked at" for the caps, and that the scans figure is the six pages' total (20% on one page). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/filters/README.md | 4 +- crates/filters/src/jbig2.rs | 18 ++-- crates/filters/src/jbig2/symbols.rs | 140 ++++++++++++++++++++-------- crates/optimize/README.md | 4 +- crates/optimize/tests/optimize.rs | 18 ++++ parity/acrobat-features.toml | 2 +- 6 files changed, 132 insertions(+), 54 deletions(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index cf8ebb6558..0b06a485b6 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -44,7 +44,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by an immediate lossless text region referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 symbols or 16 MiB of symbol pixels, or more than 2^24 rows, and the generic coding is used | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 32,767 symbols (that decoder refuses more) or 16 MiB of symbol pixels, or more than 2^24 rows, and the generic coding is used | ## Tests @@ -54,7 +54,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 symbols or 2^24 symbol pixels the image is coded generically, and at each cap it is not; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 32,767 symbols or 2^24 symbol pixels the image is coded generically, at each cap it is still looked at, and at the two symbol caps it decodes; more than 10,000 marks are placed by several text regions, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs index d3b37e5aef..343fbbd27d 100644 --- a/crates/filters/src/jbig2.rs +++ b/crates/filters/src/jbig2.rs @@ -139,16 +139,18 @@ fn at_pixels(out: &mut Vec) { } /// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), -/// then its data. `retained`: a later segment refers to this one. Segment numbers here are below -/// 256, so each referred-to one takes a byte (7.2.5); a segment refers to at most four, so their -/// count and retain bits take one byte too (7.2.4), and the retain bits of the referred-to ones -/// are 0: nothing after this segment refers to them. -fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[u8], retained: bool, data: &[u8]) { +/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each +/// referred-to segment's number and whether a segment after this one refers to it too (its +/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte +/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too. +fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { out.extend_from_slice(&number.to_be_bytes()); out.push(kind); - let count = refers_to.len().min(4); - out.push((count as u8) << 5 | u8::from(retained)); - out.extend_from_slice(refers_to.get(..count).unwrap_or_default()); + // At most four, so the count fits its three bits. + let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default(); + let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1)); + out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); + out.extend(refers_to.iter().map(|(n, _)| *n)); out.push(1); // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index e4c79bc585..a34653e748 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -1,8 +1,8 @@ //! JBIG2 symbol coding, lossless: each distinct mark of an image (a group of black pixels that -//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and a -//! text region places a copy of it wherever it occurs. Marks too large to be symbols (rules, -//! frames, pictures) are left to a generic region beside it, and so, in [`encode`]'s second plan, -//! are marks that occur only once. Only exact copies share a symbol, so the image decodes to the +//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and +//! text regions place a copy of it wherever it occurs. Marks too large to be symbols (rules, +//! frames, pictures) are left to a generic region beside them, and so, in [`encode`]'s second +//! plan, are marks that occur only once. Only exact copies share a symbol, so the image decodes to the //! same bits. Written from the committee draft's 6.4 (the text region decoding procedure), 6.5 //! (the symbol dictionary decoding procedure), 7.4.2 and 7.4.3 (their segments) and Annex A (the //! arithmetic integer coders). @@ -21,10 +21,18 @@ const MAX_SYMBOL_SIDE: u32 = 256; /// which suits it better anyway; this bounds the memory the marks take. const MAX_RUNS: usize = 1 << 20; -/// The most distinct symbols, and the most pixels their bitmaps may hold together. -const MAX_SYMBOLS: usize = 1 << 16; +/// The most distinct symbols, and the most pixels their bitmaps may hold together. The decoder +/// PdfCraft renders JBIG2 with (`hayro-jbig2`) refuses 32,768 symbols or more, and others may +/// too: past it the image is coded generically. +const MAX_SYMBOLS: usize = (1 << 15) - 1; const MAX_SYMBOL_PIXELS: usize = 1 << 24; +/// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with +/// refuses a text region of more than 10,000 (a page it then draws blank), so a page with more is +/// placed by several regions, each over a band of rows, all referring to the one dictionary. At +/// most [`MAX_RUNS`] marks keeps their segment numbers below 256. +const MAX_REGION_INSTANCES: usize = 10_000; + /// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and /// difference coded then fits the 32-bit integers Annex A codes. const MAX_ROWS: u32 = 1 << 24; @@ -73,14 +81,15 @@ struct Page { large: Vec, } -/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes, as S (the column) -/// and T (the row). `width` is the symbol's. +/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes on the page, as S +/// (the column) and T (the row). `width` and `height` are the symbol's. #[derive(Clone, Copy, Debug)] struct Instance { id: u32, s: u32, t: u32, width: u32, + height: u32, } /// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, @@ -283,8 +292,8 @@ fn symbols(page: &mut Page) -> Option<()> { Some(()) } -/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, a text -/// region placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and +/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, text +/// regions placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and /// the other marks in a generic region. `None` with fewer than two such symbols: their IDs would /// take no bits at all (SBSYMCODELEN 0), a corner of the standard left alone here. fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { @@ -305,23 +314,37 @@ fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width }), + (Some(id), Some(mark), Some(symbol)) => { + instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width, height: symbol.height }) + } _ => left.push(*m), } } let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); let dictionary = symbol_dictionary(&kept)?; let count = u32::try_from(kept.len()).ok()?; - let text = match log_strips { - Some(l) => text_region(&mut instances, count, columns, rows, l)?, - None => (0..4).filter_map(|l| text_region(&mut instances, count, columns, rows, l)).min_by_key(Vec::len)?, - }; + // Bands of at most MAX_REGION_INSTANCES, from the top. + instances.sort_by_key(|i| (i.t, i.s)); + let mut regions = Vec::new(); + for band in instances.chunks_mut(MAX_REGION_INSTANCES) { + let top = band.iter().map(|i| i.t.saturating_add(1).saturating_sub(i.height)).min()?; + let height = band.iter().map(|i| i.t).max()?.checked_sub(top)?.checked_add(1)?; + regions.push(match log_strips { + Some(l) => text_region(band, count, columns, top, height, l)?, + None => (0..4).filter_map(|l| text_region(band, count, columns, top, height, l)).min_by_key(Vec::len)?, + }); + } let mut out = Vec::new(); segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); segment(&mut out, 1, 0, &[], true, &dictionary); - segment(&mut out, 2, 7, &[1], false, &text); + let mut number = 2; + for (k, text) in regions.iter().enumerate() { + // The dictionary stays retained for the next region, until the last (7.2.4). + segment(&mut out, number, 7, &[(1, k + 1 < regions.len())], false, text); + number += 1; + } if !left.is_empty() { - segment(&mut out, 3, 39, &[], false, &leftover_region(&left, page)?); + segment(&mut out, number, 39, &[], false, &leftover_region(&left, page)?); } Some(out) } @@ -363,20 +386,22 @@ fn symbol_dictionary(symbols: &[&Symbol]) -> Option> { Some(data) } -/// An immediate lossless text region segment's data (7.4.3) placing `instances` over the whole -/// page: arithmetic coded, no refinement, strips of 2^`log_strips` rows, each symbol's -/// bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a line's letters share their T), -/// not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts `instances` into strips, each -/// left to right. -fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, rows: u32, log_strips: u32) -> Option> { +/// An immediate lossless text region segment's data (7.4.3) placing `instances` in the band of +/// `height` rows from row `top` (which holds them): arithmetic coded, no refinement, strips of +/// 2^`log_strips` rows, each symbol's bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a +/// line's letters share their T), not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts +/// `instances` into strips, each left to right. +fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, top: u32, height: u32, log_strips: u32) -> Option> { // LOGSBSTRIPS has two bits: strips of 1, 2, 4 or 8 rows. if log_strips > 3 { return None; } let strips = 1i64 << log_strips; - instances.sort_by_key(|i| (i.t >> log_strips, i.s)); + // T counts from the band's top row. + let t = |i: &Instance| i.t.saturating_sub(top); + instances.sort_by_key(|i| (t(i) >> log_strips, i.s)); let mut data = Vec::new(); - region_information(&mut data, columns, rows, 0, 0); + region_information(&mut data, columns, height, 0, top); data.extend_from_slice(&((log_strips as u16) << 2).to_be_bytes()); data.extend_from_slice(&u32::try_from(instances.len()).ok()?.to_be_bytes()); // SBSYMCODELEN (Table 31): ⌈log2 SBNUMSYMS⌉. @@ -388,10 +413,10 @@ fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, rows: u32 // strip size, from the strip before. iadt.encode(&mut mq, Some(0)); let (mut strip_t, mut first_s) = (0i64, 0i64); - for strip in instances.chunk_by(|a, b| a.t >> log_strips == b.t >> log_strips) { - let t = i64::from(strip.first()?.t >> log_strips << log_strips); - iadt.encode(&mut mq, Some(int((t - strip_t) / strips)?)); - strip_t = t; + for strip in instances.chunk_by(|a, b| t(a) >> log_strips == t(b) >> log_strips) { + let first = i64::from(t(strip.first()?) >> log_strips << log_strips); + iadt.encode(&mut mq, Some(int((first - strip_t) / strips)?)); + strip_t = first; let mut cur_s = 0; for (k, instance) in strip.iter().enumerate() { let s = i64::from(instance.s); @@ -402,7 +427,7 @@ fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, rows: u32 iads.encode(&mut mq, Some(int(s - cur_s)?)); } if strips > 1 { - iait.encode(&mut mq, Some(int(i64::from(instance.t) - strip_t)?)); + iait.encode(&mut mq, Some(int(i64::from(t(instance)) - strip_t)?)); } encode_id(&mut mq, &mut ids, length, instance.id); // The next S counts from this symbol's last column (6.4.5 step 3c x). @@ -603,15 +628,20 @@ mod tests { page } - /// The segments of a stream in the embedded organisation, from their headers (7.2): each one's - /// type, whether it is retained, and the segments it refers to. - fn segments(data: &[u8]) -> Vec<(u8, bool, Vec)> { + /// A segment's type, whether it is retained, and the segments it refers to, each with its + /// retain bit. + type Segment = (u8, bool, Vec<(u8, bool)>); + + /// The segments of a stream in the embedded organisation, from their headers (7.2). + fn segments(data: &[u8]) -> Vec { let (mut segments, mut at) = (Vec::new(), 0); while at < data.len() { - let refers = usize::from(data[at + 5] >> 5); + let flags = data[at + 5]; + let refers = usize::from(flags >> 5); let length_at = at + 6 + refers + 1; let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; - segments.push((data[at + 4] & 0x3F, data[at + 5] & 1 == 1, data[at + 6..at + 6 + refers].to_vec())); + let referred = data[at + 6..at + 6 + refers].iter().enumerate().map(|(k, n)| (*n, flags >> (k + 1) & 1 == 1)).collect(); + segments.push((data[at + 4] & 0x3F, flags & 1 == 1, referred)); at = length_at + 4 + length; } segments @@ -752,7 +782,7 @@ mod tests { assert_eq!(every_plan_round_trips(&picture), 8); } - /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at 2^20 runs it is + /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at either cap it is /// still looked at. #[test] fn busy_or_tall_images_are_left_to_generic_coding() { @@ -766,8 +796,8 @@ mod tests { assert!(page(&tall, 1, MAX_ROWS).is_some()); } - /// Past 65,536 distinct symbols, or 2^24 pixels of them, the image is left to generic coding; - /// at either cap it is still looked at. + /// Past 32,767 distinct symbols, or 2^24 pixels of them, the image is left to generic coding; + /// at either cap it is symbol coded, and decodes. #[test] fn too_many_symbols_are_left_to_generic_coding() { // `n` distinct marks of 17 × 3: full top and bottom rows joined by a code of 1 to n, in @@ -785,8 +815,12 @@ mod tests { } (samples(&picture), columns as u32, rows as u32) }; + // At the cap the symbols are coded, and an independent decoder reads them back. let (data, columns, rows) = codes(MAX_SYMBOLS); - assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(at_cap.symbols.len(), MAX_SYMBOLS); + let coded = plan(&at_cap, columns, rows, 1, None).unwrap(); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); let (data, columns, rows) = codes(MAX_SYMBOLS + 1); assert!(page(&data, columns, rows).is_none()); // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 @@ -804,11 +838,35 @@ mod tests { (samples(&picture), columns as u32, rows as u32) }; let (data, columns, rows) = squares(256); - assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(256)); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(at_cap.symbols.len(), 256); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); let (data, columns, rows) = squares(257); assert!(page(&data, columns, rows).is_none()); } + /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more + /// than the decoder PdfCraft renders with accepts in one), over bands of rows, all referring to + /// the dictionary, which each region but the last keeps retained. + #[test] + fn more_than_10000_marks_are_placed_by_several_regions() { + // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart. + let mut picture = vec![vec![0u8; 256 * 18]; 25_000usize.div_ceil(256) * 5]; + for k in 0..25_000 { + let (x, y) = (k % 256 * 18, k / 256 * 5); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + picture[y + 1][x + k % 2] = 1; + } + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + let region = |kept| (7, false, vec![(1, kept)]); + assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), region(true), region(true), region(false)]); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: /// a symbol dictionary and a text region, much smaller than the generic coding. #[test] @@ -818,7 +876,7 @@ mod tests { let coded = encode_jbig2(&data, 1700, 400).unwrap(); let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). - assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), (7, false, vec![1])]); + assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), (7, false, vec![(1, false)])]); assert_eq!(decode(&coded), unpack(&data, 1700, 400)); assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); assert_eq!(every_plan_round_trips(&picture), 8); diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 8648e39d86..928a2fbacd 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -42,8 +42,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going symbol, when the image's marks repeat exactly, as rendered text's do: on six pages of 300 dpi text that came to 28% of a generic region's size and 17% of Group 4's. On scans, whose copies of a letter are seldom identical, symbols gain little: on the same pages with 2% - or more of the letters' edge pixels flipped at random, at most 8% below a generic region, - which was 27–33% below Group 4. It is lossless: the + or more of the letters' edge pixels flipped at random, at most 8% below a generic region in + total (20% on one page), and the generic region was 27–33% below Group 4. It is lossless: the samples decode to the same bits, so the colour space, decode array and masks stay. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Not yet: diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index a4315e2392..a349d8cb1b 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -734,6 +734,24 @@ fn rendered_text_becomes_jbig2_symbols() { assert!(render(&doc) == before, "the page looks the same"); } +/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the +/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. +#[test] +fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (2550, 3300); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 612 0 0 792 0 0 cm /Im Do Q"); + let before = render(&doc); + let r = optimize(&mut doc, &Settings { mono: MonoCompression::Jbig2, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded"); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2a83a68edf..a57ed83df6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7385,7 +7385,7 @@ status = "partial" commands = ["optimize.advanced", "optimize.reduce"] tools = ["doc_optimize", "doc_reduce"] evidence = ["crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy"] -notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with a text region, larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, little: on the same pages with 2% or more of the letters' edge pixels flipped at random, at most 8% below the generic region, which was 27–33% below Group 4. A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." +notes = "CCITT Group 4 (the default, so Reduce File Size does it too) or JBIG2, both lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. JBIG2 stores each distinct mark once in a symbol dictionary and places its exact copies with text regions (at most 10,000 copies each), larger and lone marks in a generic region (template 0, typical prediction), or the whole image as a generic region, whichever is smaller: on rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's; on scans, whose copies of a letter are seldom identical, little: on the same pages with 2% or more of the letters' edge pixels flipped at random, at most 8% below the generic region in total (20% on one page), and the generic region was 27–33% below Group 4. A document below PDF 1.4 gets /Version 1.4. Not yet: JBIG2 refinement coding of near-copies (where lossless gains on scans would come from), a symbol dictionary shared between images (/JBIG2Globals), JBIG2's lossy mode, monochrome downsampling, inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples (JBIG2 takes rows of up to 1,048,576). Redaction doesn't decode JBIG2 or Group 4 yet: a mark over part of such an image removes the whole image (nothing leaks). Export reads JBIG2, but nothing that exports an image reads Group 4 yet: Export ▸ Images and Save Image As refuse it, Copy Image fails, and Word, PowerPoint and HTML export leave the picture out." [[feature]] id = "optimize.only-if-smaller" From fe05b73d08bf2095cf9d769042df1f255665ebe4 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 12:09:03 +0100 Subject: [PATCH 79/82] M11: JBIG2 symbol coding: tests for region numbering; the symbol cap applies to each plan Review of the previous commit: - Nothing checked the segment numbers the region split assigns: a mutant numbering every region 2 decoded. The many-marks test now reads each segment's number, and has a bar too tall to be a symbol, so the generic region is numbered after the last text region. - The decoder's 32,767-symbol limit was checked against all distinct marks, before either plan, so an image with more distinct marks lost the second plan, which codes only the repeated ones. The limit now applies to each plan (MAX_CODED_SYMBOLS); finding marks keeps its memory bound of 65,536 (MAX_SYMBOLS), as before the previous commit. A test has 32,768 distinct marks above a page of repeated text, which encode_jbig2 still symbol codes through the second plan. - The dense-page optimizer test now checks that it takes several text regions, and a compile-time assert keeps segment numbers within a byte's reach. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/filters/README.md | 4 +- crates/filters/src/jbig2/symbols.rs | 114 +++++++++++++++++++--------- crates/optimize/tests/optimize.rs | 11 +++ 3 files changed, 93 insertions(+), 36 deletions(-) diff --git a/crates/filters/README.md b/crates/filters/README.md index 0b06a485b6..333ee1e59b 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -44,7 +44,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | | CCITTFaxDecode | not here (`Unsupported`; renderers decode it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | -| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 32,767 symbols (that decoder refuses more) or 16 MiB of symbol pixels, or more than 2^24 rows, and the generic coding is used | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests @@ -54,7 +54,7 @@ encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // t - `ccitt.rs`: Group 4 output decoded by `hayro-ccitt` (dev-dependency, an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. - `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. - `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. -- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 32,767 symbols or 2^24 symbol pixels the image is coded generically, at each cap it is still looked at, and at the two symbol caps it decodes; more than 10,000 marks are placed by several text regions, the dictionary retained for each but the last; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs index a34653e748..e9fed24441 100644 --- a/crates/filters/src/jbig2/symbols.rs +++ b/crates/filters/src/jbig2/symbols.rs @@ -21,18 +21,25 @@ const MAX_SYMBOL_SIDE: u32 = 256; /// which suits it better anyway; this bounds the memory the marks take. const MAX_RUNS: usize = 1 << 20; -/// The most distinct symbols, and the most pixels their bitmaps may hold together. The decoder -/// PdfCraft renders JBIG2 with (`hayro-jbig2`) refuses 32,768 symbols or more, and others may -/// too: past it the image is coded generically. -const MAX_SYMBOLS: usize = (1 << 15) - 1; +/// The most distinct marks sorted into symbols, and the most pixels their bitmaps may hold +/// together: past either the image is left to generic coding. These bound the memory they take. +const MAX_SYMBOLS: usize = 1 << 16; const MAX_SYMBOL_PIXELS: usize = 1 << 24; +/// The most symbols one plan codes. The decoder PdfCraft renders JBIG2 with (`hayro-jbig2`) +/// refuses 32,768 symbols or more, and others may too, so a plan with more isn't made. +const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; + /// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with /// refuses a text region of more than 10,000 (a page it then draws blank), so a page with more is -/// placed by several regions, each over a band of rows, all referring to the one dictionary. At -/// most [`MAX_RUNS`] marks keeps their segment numbers below 256. +/// placed by several regions, each over a band of rows, all referring to the one dictionary. const MAX_REGION_INSTANCES: usize = 10_000; +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the page, the +// dictionary, a text region for each MAX_REGION_INSTANCES of at most MAX_RUNS marks, and the +// generic region. +const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); + /// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and /// difference coded then fits the 32-bit integers Annex A codes. const MAX_ROWS: u32 = 1 << 24; @@ -94,7 +101,8 @@ struct Instance { /// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, /// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. -/// `None` when neither plan has two symbols, or the image is too large or too busy to look at. +/// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too +/// large or too busy to look at. pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { let page = page(samples, columns, rows)?; [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) @@ -294,13 +302,14 @@ fn symbols(page: &mut Page) -> Option<()> { /// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, text /// regions placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and -/// the other marks in a generic region. `None` with fewer than two such symbols: their IDs would -/// take no bits at all (SBSYMCODELEN 0), a corner of the standard left alone here. +/// the other marks in a generic region. `None` with fewer than two such symbols (their IDs would +/// take no bits at all, SBSYMCODELEN 0, a corner of the standard left alone here) or more than +/// [`MAX_CODED_SYMBOLS`]. fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { let symbol = |s: u32| page.symbols.get(s as usize); // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); - if kept.len() < 2 { + if kept.len() < 2 || kept.len() > MAX_CODED_SYMBOLS { return None; } kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); @@ -628,9 +637,9 @@ mod tests { page } - /// A segment's type, whether it is retained, and the segments it refers to, each with its - /// retain bit. - type Segment = (u8, bool, Vec<(u8, bool)>); + /// A segment's number and type, whether it is retained, and the segments it refers to, each + /// with its retain bit. + type Segment = (u32, u8, bool, Vec<(u8, bool)>); /// The segments of a stream in the embedded organisation, from their headers (7.2). fn segments(data: &[u8]) -> Vec { @@ -641,14 +650,15 @@ mod tests { let length_at = at + 6 + refers + 1; let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; let referred = data[at + 6..at + 6 + refers].iter().enumerate().map(|(k, n)| (*n, flags >> (k + 1) & 1 == 1)).collect(); - segments.push((data[at + 4] & 0x3F, flags & 1 == 1, referred)); + let number = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); + segments.push((number, data[at + 4] & 0x3F, flags & 1 == 1, referred)); at = length_at + 4 + length; } segments } fn segment_types(data: &[u8]) -> Vec { - segments(data).into_iter().map(|(kind, ..)| kind).collect() + segments(data).into_iter().map(|(_, kind, ..)| kind).collect() } /// Every plan, at every strip size, decodes to the picture; returns how many plans there were. @@ -796,32 +806,60 @@ mod tests { assert!(page(&tall, 1, MAX_ROWS).is_some()); } - /// Past 32,767 distinct symbols, or 2^24 pixels of them, the image is left to generic coding; - /// at either cap it is symbol coded, and decodes. + /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, + /// the second plan can still code the repeated ones. Past 65,536 distinct marks, or 2^24 + /// pixels of them, the image is left to generic coding; at either cap it is still looked at. #[test] fn too_many_symbols_are_left_to_generic_coding() { - // `n` distinct marks of 17 × 3: full top and bottom rows joined by a code of 1 to n, in - // binary, on the row between. - let codes = |n: usize| { - let (columns, rows) = (256 * 18, n.div_ceil(256) * 4); + // `n` distinct marks of 17 × 3 (full top and bottom rows joined by a code of 1 to n, in + // binary, on the row between), then `again` more with the codes 1 and 2 in turn. + let codes = |n: usize, again: usize| { + let (columns, rows) = (256 * 18, (n + again).div_ceil(256) * 4); let mut picture = vec![vec![0u8; columns]; rows]; - for k in 0..n { + for k in 0..n + again { let (x, y) = (k % 256 * 18, k / 256 * 4); + let code = if k < n { k + 1 } else { (k - n) % 2 + 1 }; picture[y][x..x + 17].fill(1); picture[y + 2][x..x + 17].fill(1); - for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + for b in (0..17).filter(|b| code >> b & 1 == 1) { picture[y + 1][x + b] = 1; } } (samples(&picture), columns as u32, rows as u32) }; - // At the cap the symbols are coded, and an independent decoder reads them back. - let (data, columns, rows) = codes(MAX_SYMBOLS); + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); let at_cap = page(&data, columns, rows).unwrap(); - assert_eq!(at_cap.symbols.len(), MAX_SYMBOLS); - let coded = plan(&at_cap, columns, rows, 1, None).unwrap(); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + // One more distinct mark, each once: neither plan is made. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + // With two of them repeated, the second plan codes those two. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); + // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` + // keeps: the first, of every mark, would code too many symbols. + let mut picture: Bitmap = vec![vec![0u8; 256 * 18]; 512]; + for k in 0..=MAX_CODED_SYMBOLS { + let (x, y) = (k % 256 * 18, k / 256 * 4); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + picture.extend(text(256 * 18, 600, &glyphs(40, 7), 11)); + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + assert_eq!(segment_types(&coded)[1], 0, "symbol coded"); assert_eq!(decode(&coded), unpack(&data, columns, rows)); - let (data, columns, rows) = codes(MAX_SYMBOLS + 1); + let (data, columns, rows) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, rows) = codes(MAX_SYMBOLS + 1, 0); assert!(page(&data, columns, rows).is_none()); // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 // pixels a symbol, so 256 of them are exactly the cap. @@ -847,22 +885,30 @@ mod tests { /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more /// than the decoder PdfCraft renders with accepts in one), over bands of rows, all referring to - /// the dictionary, which each region but the last keeps retained. + /// the dictionary, which each region but the last keeps retained; the generic region comes + /// after them, every segment numbered one more than the one before. #[test] fn more_than_10000_marks_are_placed_by_several_regions() { - // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart. - let mut picture = vec![vec![0u8; 256 * 18]; 25_000usize.div_ceil(256) * 5]; + // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart, and a bar too + // tall to be a symbol beside them. + let mut picture = vec![vec![0u8; 256 * 18 + 100]; 25_000usize.div_ceil(256) * 5]; for k in 0..25_000 { let (x, y) = (k % 256 * 18, k / 256 * 5); picture[y][x..x + 17].fill(1); picture[y + 2][x..x + 17].fill(1); picture[y + 1][x + k % 2] = 1; } + for row in &mut picture[0..300] { + row[256 * 18 + 50] = 1; + } let data = samples(&picture); let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); let coded = encode_jbig2(&data, columns, rows).unwrap(); - let region = |kept| (7, false, vec![(1, kept)]); - assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), region(true), region(true), region(false)]); + let region = |number, kept| (number, 7, false, vec![(1, kept)]); + assert_eq!( + segments(&coded), + [(0, 48, false, vec![]), (1, 0, true, vec![]), region(2, true), region(3, true), region(4, false), (5, 39, false, vec![])] + ); assert_eq!(decode(&coded), unpack(&data, columns, rows)); assert_eq!(every_plan_round_trips(&picture), 8); } @@ -876,7 +922,7 @@ mod tests { let coded = encode_jbig2(&data, 1700, 400).unwrap(); let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). - assert_eq!(segments(&coded), [(48, false, vec![]), (0, true, vec![]), (7, false, vec![(1, false)])]); + assert_eq!(segments(&coded), [(0, 48, false, vec![]), (1, 0, true, vec![]), (2, 7, false, vec![(1, false)])]); assert_eq!(decode(&coded), unpack(&data, 1700, 400)); assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); assert_eq!(every_plan_round_trips(&picture), 8); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index a349d8cb1b..f8e28506a1 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -749,6 +749,17 @@ fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() { let s = stream(&doc, im); assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded"); + // The copies take several text regions (segment type 7, ISO/IEC 14492 7.3), read from the + // segment headers (7.2): number, flags, referred-to count and numbers, page, data length. + let mut regions = 0; + let mut at = 0; + while let Some(header) = s.raw.get(at..at + 6) { + let refers = usize::from(header[5] >> 5); + let length = u32::from_be_bytes(s.raw[at + 7 + refers..at + 11 + refers].try_into().unwrap()) as usize; + regions += usize::from(header[4] & 0x3F == 7); + at += 11 + refers + length; + } + assert!(regions > 1, "{regions} text regions"); assert!(render(&doc) == before, "the page looks the same"); } From f3a547d71797788570bb59ad584787e14ac27438 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 13:56:37 +0100 Subject: [PATCH 80/82] =?UTF-8?q?M11:=20a=20test=20that=20Export=20?= =?UTF-8?q?=E2=96=B8=20Images=20reads=20symbol-coded=20JBIG2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optimizer now writes JBIG2 images as a symbol dictionary and text regions, and Export ▸ Images has to read them back. A page of three repeated letters, symbol coded (smaller than the generic coding), exports as its picture. The test is added to create.export-all-images' parity evidence. Asked for in pre-push review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- crates/create/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9bff0c311c..352ff14eb4 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -1417,6 +1417,45 @@ fn images_whose_predictor_is_given_by_reference_export_as_their_picture() { assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference"); } +/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather +/// than one generic region) exports as its picture. +#[test] +fn symbol_coded_jbig2_images_export_as_their_picture() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + // 1 samples white, as a grey image's are; the letters' pixels 0. + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + if c == b'#' { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + } + let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len()); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| { + let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(d, jbig2) + }); + let picture: Vec = (0..w * h) + .flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }) + .collect(); + assert_eq!(exported_rgba(&doc), ("png", picture)); +} + /// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as /// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in /// an array, so globals that can't be read skip the image rather than being ignored. Rows past the diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index a57ed83df6..9e9f1ae804 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -7164,7 +7164,7 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2"] notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JBIG2 images and masks decode (JBIG2 as the only filter, with /JBIG2Globals; an image whose JBIG2 page has another width, or more pixels than an export takes, or whose globals decode to more than 32 MiB, is skipped, and such a mask is left out, the image exported opaque); JPX/CCITT masks are left out (image exported opaque); JPX/CCITT/separations, and JBIG2 inside other filters, reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] From c7f39a4d9a341dcbdacaf3bc131ea2825f030b13 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Mon, 26 Oct 2026 18:14:26 +0100 Subject: [PATCH 81/82] M11: the parity table no longer lists JBIG2 symbol coding as missing docs/target-app-parity.md's optimize row lists lossy JBIG2, refinement coding and a symbol dictionary shared between images as missing, with a status line and a revision row. From reviews with the pdfcraft-review skill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01P6yUtvWGPsJpMLyh6pxSXG --- docs/target-app-parity.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 22832f65f2..d48333c185 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (K Optimize and standards no longer lists JBIG2 symbol coding as missing; scores and estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (K Optimize and standards no longer lists lossless JBIG2 as missing; scores and estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-26 · **Change:** minor (`optimize.mono-compression` partial (CCITT Group 4): K Optimize and standards 15 shipped and 11 partial of 58, 35.3%; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497); estimates unchanged) > **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) @@ -210,7 +211,7 @@ full parity in that area. | H Digital signatures | 45 | 23 | 10 | 62.2% | 45% | 40–70 | PAdES B-B signing and validation (PKCS #12, macOS Keychain, Windows store with PIN prompts), certification, encrypted documents, certificate usage checks; B-T, LTV, OCSP/CRL with caller-supplied evidence. Missing: PKCS #11 and smart cards outside Windows, timestamp-server preferences, online revocation fetching, FieldMDP and field locking, OS trust store, EU Trusted Lists, compare signed version | | I Scan and OCR | 26 | 5 | 2 | 23.1% | 15% | 40–75 | Searchable image (ocrs, Latin script without accents) on pages, ranges and many files. Missing: every other language (#744 Dutch; accents, CJK, Cyrillic, Arabic), editable-text output, deskew, rotation, despeckle, camera images beyond straightening (found or placed corners): orientation, clean-up, MRC compression, scanners | | J Create and export | 34 | 12 | 3 | 39.7% | 25% | 55–100 | Create from images, multipage TIFF, text, clipboard, blank, many files at once; export PNG/JPEG/TIFF, text, RTF; Word and HTML partial. Missing: Office/HTML/web/PostScript to PDF, Excel and PowerPoint export, Word layout fidelity (#773), SVG/XML/EPS/PS/JPEG 2000 export, accessible text | -| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, JBIG2 symbol coding and lossy JBIG2, monochrome downsampling | +| K Optimize and standards | 58 | 15 | 11 | 35.3% | 20% | 65–115 | Reduce File Size and the Optimizer (images, discards, clean-up, space audit); PDF/A-2b/3b verify and convert (partial). Missing: Preflight (profiles, fixups, reports), PDF/A-1/4 and a/u levels, PDF/X, PDF/UA, PDF/E, PDF/VT, transparency flattening, font unembedding, lossy JBIG2, JBIG2 refinement coding and a symbol dictionary shared between images, monochrome downsampling | | L Print | 39 | 15 | 1 | 39.7% | 35% | 30–55 | Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, print-ready PDF, CUPS on macOS/Linux, the Windows spooler (#756) and the browser's print dialog on the web. Missing: print as image, PostScript, all print-production tools (output preview, separations, ink manager, printer marks, colour conversion) | | M Accessibility | 61 | 38 | 1 | 63.1% | 30% | 40–70 | Accessibility Checker (all 32 rules, report, fixes), alternate text, title and language. Missing: autotag, Tags/Order/Content panels, Reading Order tool, artifact marking, table editor, Read Out Loud, full keyboard operation of the app, screen-reader audit | | N Automation, preferences, misc | 75 | 20 | 5 | 30.0% | 35% | 25–45 | CLI, MCP, UI control channel, 141 automation tools, Action Wizard (run, create), compare (text, visual, report, side by side, Text/Visual filter). Missing: most Preferences pages, Acrobat's finer compare filters, Action Wizard management, custom commands, AI providers, rich media/3D, hosted web app | @@ -296,6 +297,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | K Optimize and standards no longer lists JBIG2 symbol coding as missing (lossless, exact copies); lossy JBIG2, refinement coding and a dictionary shared between images still are. Scores and estimates unchanged. | | 2026-10-26 | minor | K Optimize and standards no longer lists lossless JBIG2 as missing (a generic region, through the Optimizer's Monochrome Images); symbol coding and lossy JBIG2 still are. Scores and estimates unchanged. | | 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. | | 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | From e19f9646906df51c287f9b049feb8199cd645996 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:10:25 +0100 Subject: [PATCH 82/82] Handle the current fallible FDF export API in the round-trip regression --- crates/xfdf/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 1f35f82b70..0da17c1e67 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1254,7 +1254,7 @@ fn edited_button_export_values_round_trip_in_all_data_formats() { set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap(); set_value(&mut doc, "Size", &FieldValue::Radio(Some("Small".into()))).unwrap(); let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); - let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "")]; + let mut exports = vec![export_xfdf(&doc, false, true, "").into_bytes(), export_fdf(&doc, false, true, "").unwrap()]; exports.extend([Format::Xml, Format::Csv, Format::Txt].map(|f| export_data(&doc, f).into_bytes())); for (index, bytes) in exports.into_iter().enumerate() { if index < 2 {