From cfb71d850e99a41cd47e0c87e2f8449b8abf6a9e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:29 -0400 Subject: [PATCH 01/20] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587a8..01cb9cd663 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From d695bc56545944fe9c7181901c61d0bf1c11c887 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Tue, 20 Oct 2026 18:17:34 -0600 Subject: [PATCH 02/20] fix(redact): evaluate OCMD visibility during sanitization (#3522) * fix(redact): evaluate OCMD visibility during sanitization Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively. * fix(redact): preserve OCMD visibility during sanitization * fix(redact): count hidden XObjects during verification * test(redact): cover hidden form depth limit --- crates/redact/src/interp.rs | 251 +++++++++++++++++++-- crates/redact/src/sanitize.rs | 32 ++- crates/redact/src/tests.rs | 414 ++++++++++++++++++++++++++++++++++ 3 files changed, 667 insertions(+), 30 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 52ca6c276f..415382b232 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> { pub report: &'a mut Report, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, - /// Sanitize: optional content groups (and membership dictionaries) that are off; content - /// marked with them is removed. - pub hidden_layers: Vec, + /// Sanitize: optional content groups that are off; content marked with them is removed. + pub hidden_layers: HashSet, + /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful: + /// policies such as `/AllOff` can still hide content when every group is on. + pub layers_enabled: bool, + /// Off groups referenced by optional content that remains visible and therefore still needs + /// its original default state after sanitization. + pub retained_off_layers: HashSet, + /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions. + ocmd_cache: HashMap, + /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them + /// by the containing XObject so repeated `Do` calls share the same bounded evaluation. + xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, fonts: HashMap, Rc>, @@ -101,7 +111,11 @@ impl<'a> Scope<'a> { mode, report, hidden_text: None, - hidden_layers: Vec::new(), + hidden_layers: HashSet::new(), + layers_enabled: false, + retained_off_layers: HashSet::new(), + ocmd_cache: HashMap::new(), + xobject_oc_cache: HashMap::new(), layer_blocks: 0, fonts: HashMap::new(), used_names: Vec::new(), @@ -111,20 +125,26 @@ impl<'a> Scope<'a> { /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { - self.hidden_text.is_some() || !self.hidden_layers.is_empty() + self.hidden_text.is_some() || self.layers_enabled } /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&self, doc: &Document, o: &Object) -> bool { - if self.hidden_layers.is_empty() { + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { return false; } - if let Some(r) = o.as_ref() - && self.hidden_layers.contains(&r) - { - return true; + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } } - // A membership dictionary (default policy AnyOn): hidden when all its groups are. + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. let d = doc.resolve(o); let Some(d) = d.as_dict() else { return false }; if d.name(b"Type") != Some(b"OCMD") { @@ -135,7 +155,188 @@ impl<'a> Scope<'a> { Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), None => Vec::new(), }; - !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g)) + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found } fn hits(&self, b: [f64; 4]) -> bool { @@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); - if !scope.hidden_layers.is_empty() { + if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); parsed.ops = kept; @@ -928,11 +1129,13 @@ fn xobject( let obj = doc.get(r); let Object::Stream(s) = &*obj else { return None }; match s.dict.name(b"Subtype") { - _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => { + _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => { + // Verify mode must count hidden XObjects too: scan uses that count to decide + // whether the HiddenLayers pass is necessary. + scope.layer_blocks += 1; if scope.mode == Mode::Verify { return None; } - scope.layer_blocks += 1; out.replaced.push(name); out.doomed.push(r); Some(None) @@ -983,6 +1186,11 @@ fn xobject( } if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { + // Sanitization removes a Form it cannot enter, so the verification scan + // must count that conservative removal or it may decide to skip the pass. + if scope.everywhere() { + scope.layer_blocks += 1; + } return None; } scope.report.forms_removed += 1; @@ -998,7 +1206,14 @@ fn xobject( out.doomed.push(r); return Some(None); } - Err(_) => return None, + Err(_) => { + // Apply drops an undecodable Form while sanitizing. Count it during Verify + // so a zero-count preflight cannot leave its potentially hidden bytes behind. + if scope.everywhere() { + scope.layer_blocks += 1; + } + return None; + } }; let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()); let res = own.clone().unwrap_or_else(|| resources.clone()); @@ -1073,7 +1288,7 @@ fn xobject( /// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks /// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { +fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { let mut out = Vec::with_capacity(ops.len()); // For each open marked-content block: does it hide its contents? let mut stack: Vec = Vec::new(); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1940e376a8..bafa769bd3 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec { } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). -fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result { +fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> { let off = if layers { hidden_layers(doc) } else { Vec::new() }; - if !text && off.is_empty() { - return Ok(0); + if !text && !layers { + return Ok((0, std::collections::HashSet::new())); } let mut total = 0; + let mut retained_off_layers = std::collections::HashSet::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); let mut retired_properties = Vec::new(); @@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); } - scope.hidden_layers = off.clone(); + scope.hidden_layers = off.iter().copied().collect(); + scope.layers_enabled = layers; let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); if let Some(error) = out.error { return Err(error.into()); } let blocks = scope.layer_blocks; - total += out.residue + blocks + report.glyphs; + let glyphs = scope.report.glyphs; + let retained_for_page = std::mem::take(&mut scope.retained_off_layers); + drop(scope); + total += out.residue + blocks + glyphs; + retained_off_layers.extend(retained_for_page); if !write { continue; } @@ -216,7 +222,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; - Ok(total) + Ok((total, retained_off_layers)) } /// How many items each category would remove (categories with nothing are included as 0). @@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), - Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0), + Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); - if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) } + off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0) } Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)), Hidden::LinksActionsScripts => { @@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { - content_pass(doc, text, layers, true)?; - } + let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; // Every annotation the pages list now: whatever is no longer listed at the end (flattened // widgets included) leaves the structure tree too. let listed = |doc: &Document| -> std::collections::HashSet { @@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { - let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r)); + let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r)); if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect())); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index da1ecf12ab..8bc5ec1418 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() { assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save"); } +#[test] +fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + let before = pdfcraft_edit::text_lines(&doc, 0).unwrap(); + assert!(before.iter().any(|line| line.text.contains("PUBLIC"))); + assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}"); + assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}"); + assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET")); +} + +#[test] +fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/Fm Do", + "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>", + b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC", + ), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Unused) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(), + ], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ve_not_is_sanitized_when_its_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document { + let membership = match policy { + "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>", + _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + }; + let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") }; + let xobject = match subtype { + "Image" => stream( + &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"), + b"PRIVATE_HIDDEN_XOBJECT_IMAGE", + ), + _ => stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"), + b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET", + ), + }; + let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()]; + if object_number == 9 { + extras.push(membership.as_bytes().to_vec()); + } + extras.push(xobject); + let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))), + ])), + ); + }) + .unwrap(); + doc +} + +#[test] +fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut failures = Vec::new(); + for subtype in ["Image", "Form"] { + for policy in ["AllOff", "Not"] { + let mut doc = all_on_ocmd_xobject(subtype, policy); + let before = scan(&doc); + let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n); + if hidden_count == 0 { + failures.push(format!("{subtype} /{policy} was not counted: {before:?}")); + } + + match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) { + Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {} + Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")), + Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")), + } + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") { + failures.push(format!("{subtype} /{policy} image payload remains in full save")); + } + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") { + failures.push(format!("{subtype} /{policy} Form payload remains in full save")); + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) { + failures.push(format!("{subtype} /{policy} Form text remains extractable")); + } + if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() { + failures.push(format!("{subtype} /{policy} image remains on the page")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() { + use crate::sanitize::{Hidden, scan}; + + let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff"); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do"))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap(); + + let original = write_full(&doc, &SaveOptions::default()).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document"); +} + +#[test] +fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/Bad Do", + "/XObject << /Bad 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM")); +} + +#[test] +fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; + // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed + // conservatively without being entered. Verify must count that removal or sanitize + // can skip the HiddenLayers pass entirely. + let mut forms = Vec::new(); + for depth in 0..=12 { + let content = if depth == 12 { + b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() + } else { + format!("/F{} Do", depth + 1).into_bytes() + }; + forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); + } + let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); + let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); + assert!( + write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), + "fixture lacked nested payload before sanitization" + ); + + let counts = scan(&doc); + assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}"); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + assert!( + removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), + "depth-limited Form was not reported removed: {removed:?}" + ); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save"); +} + +#[test] +fn hidden_ocmd_resolves_an_indirect_policy_name() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(), + ], + ); + doc.set(ObjRef::new(12, 0), Object::name("AnyOn")); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); +} + +#[test] +fn surviving_anyoff_and_not_content_keeps_its_off_group_state() { + use crate::sanitize::{Hidden, remove_hidden}; + + for use_not in [false, true] { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() }, + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let root = doc.root().unwrap(); + let catalog = doc.get(root); + let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o)); + let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o)); + let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o)); + assert!( + off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))), + "off group lost for /VE Not={use_not}" + ); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); + } +} + +#[test] +fn hidden_ocmd_policy_matches_the_default_configuration() { + use crate::sanitize::{Hidden, remove_hidden}; + + let cases = [ + (None, false, None, false), + (Some("AnyOn"), false, None, false), + (Some("AllOn"), false, None, true), + (Some("AnyOff"), false, None, false), + (Some("AllOff"), false, None, true), + (Some("UnknownPolicy"), false, None, true), + (None, true, None, true), + (None, false, Some("and"), true), + (None, false, Some("and_single_not"), true), + (None, false, Some("and_single_off"), true), + (None, false, Some("or_single_on"), false), + (None, false, Some("or_single_off"), true), + (Some("AllOn"), false, Some("not_off"), false), + (None, false, Some("malformed_not"), true), + (None, false, Some("malformed_without_ocgs"), true), + ]; + for (policy, malformed_policy, visibility, should_remove) in cases { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let mut membership = Dict::new(); + membership.set(b"Type".to_vec(), Object::name("OCMD")); + if visibility != Some("malformed_without_ocgs") { + membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])); + } + if let Some(policy) = policy { + membership.set(b"P".to_vec(), Object::name(policy)); + } else if malformed_policy { + membership.set(b"P".to_vec(), Object::Int(17)); + } + match visibility { + Some("and") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("and_single_not") => membership.set( + b"VE".to_vec(), + Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]), + ), + Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])), + Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])), + Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])), + Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_not") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_without_ocgs") => { + membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))])) + } + _ => {} + } + doc.set(ObjRef::new(9, 0), Object::Dict(membership)); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")); + assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}"); + } +} + #[test] fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { // A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA From 3e3064532f04fd6ec769c6bd7b2c97464757970e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 15:27:33 -0400 Subject: [PATCH 03/20] Release: PdfCraft v0.11.0 (#3893) * Release: PdfCraft v0.11.0 - Bump the workspace to 0.11.0. - Fix main's red CI (cargo-deny): update rustls-webpki 0.103.15 -> 0.103.16 (RUSTSEC-2026-0382) and webpki-root-certs 1.0.9 -> 1.0.10 (RUSTSEC-2026-0383), both reached through winprint's sigstore build dependency. - `cargo xtask version set` now moves the workspace crates' pins in [workspace.dependencies] with the version; before, `cargo update --workspace` refused the bump and the pins had to be edited by hand. Co-Authored-By: Claude Opus 5.5 * Tests: a healthy private retry may answer during the watchdog pass that scheduled it shared_timeouts_retry_healthy_pages_privately_before_blacklisting failed on CI (PR #3893): the try_recv that runs the watchdog spawns the private retries, and page 1's retry has no gate, so on a loaded runner its healthy result arrived before that same call read the channel. Accept that early answer (page 1, no error) and use it as the healthy result; an error still fails. Reproduced by sleeping 300 ms after the watchdog in try_recv: the old test fails every time, the new one passes. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 92 ++++++++++++++++++------------------- Cargo.toml | 68 +++++++++++++-------------- crates/render/src/raster.rs | 15 +++++- xtask/src/version.rs | 25 +++++++++- 4 files changed, 117 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2399531ef4..e9505abbc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3938,7 +3938,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.10.1" +version = "0.11.0" dependencies = [ "craft-quit", "eframe", @@ -3963,7 +3963,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3975,7 +3975,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3997,7 +3997,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4018,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4035,18 +4035,18 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-content" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-crypt", @@ -4060,7 +4060,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.10.1" +version = "0.11.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4076,7 +4076,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.10.1" +version = "0.11.0" dependencies = [ "aes", "md-5", @@ -4088,7 +4088,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4103,7 +4103,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.10.1" +version = "0.11.0" dependencies = [ "chrono", "craft-docpath", @@ -4143,14 +4143,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", "proptest", @@ -4160,7 +4160,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.10.1" +version = "0.11.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4170,7 +4170,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4182,18 +4182,18 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-js" -version = "0.10.1" +version = "0.11.0" dependencies = [ "boa_engine", ] [[package]] name = "pdfcraft-measure" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4206,14 +4206,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4223,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.10.1" +version = "0.11.0" dependencies = [ "image", "pdfcraft-annot", @@ -4236,11 +4236,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-organize" -version = "0.10.1" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.10.1" +version = "0.11.0" dependencies = [ "async-channel", "futures-lite", @@ -4261,7 +4261,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.10.1" +version = "0.11.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4272,7 +4272,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.10.1" +version = "0.11.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4299,7 +4299,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.10.1" +version = "0.11.0" dependencies = [ "bytemuck", "flate2", @@ -4318,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.10.1" +version = "0.11.0" dependencies = [ "aes", "aws-lc-rs", @@ -4352,11 +4352,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.10.1" +version = "0.11.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.10.1" +version = "0.11.0" dependencies = [ "arboard", "base64 0.22.1", @@ -4400,7 +4400,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.10.1" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -4412,7 +4412,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.10.1" +version = "0.11.0" dependencies = [ "base64 0.22.1", "image", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.10.1" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4934,7 +4934,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5484,7 +5484,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5554,7 +5554,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5565,9 +5565,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -6431,7 +6431,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7306,9 +7306,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -7514,7 +7514,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b00b726dcb..18afce999f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.10.1" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.10.1" } -pdfcraft-filters = { path = "crates/filters", version = "0.10.1" } -pdfcraft-cos = { path = "crates/cos", version = "0.10.1" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" } -pdfcraft-organize = { path = "crates/organize", version = "0.10.1" } -pdfcraft-measure = { path = "crates/measure", version = "0.10.1" } -pdfcraft-annot = { path = "crates/annot", version = "0.10.1" } -pdfcraft-audit = { path = "crates/audit", version = "0.10.1" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" } -pdfcraft-model = { path = "crates/model", version = "0.10.1" } -pdfcraft-content = { path = "crates/content", version = "0.10.1" } -pdfcraft-redact = { path = "crates/redact", version = "0.10.1" } -pdfcraft-print = { path = "crates/print", version = "0.10.1" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" } -pdfcraft-sign = { path = "crates/sign", version = "0.10.1" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" } -pdfcraft-js = { path = "crates/js", version = "0.10.1" } -pdfcraft-compare = { path = "crates/compare", version = "0.10.1" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" } -pdfcraft-export = { path = "crates/export", version = "0.10.1" } -pdfcraft-edit = { path = "crates/edit", version = "0.10.1" } -pdfcraft-create = { path = "crates/create", version = "0.10.1" } -pdfcraft-forms = { path = "crates/forms", version = "0.10.1" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" } -pdfcraft-render = { path = "crates/render", version = "0.10.1" } -pdfcraft-engine = { path = "crates/engine", version = "0.10.1" } -pdfcraft-platform = { path = "crates/platform", version = "0.10.1" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" } -pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } +pdfcraft-model = { path = "crates/model", version = "0.11.0" } +pdfcraft-content = { path = "crates/content", version = "0.11.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } +pdfcraft-print = { path = "crates/print", version = "0.11.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } +pdfcraft-js = { path = "crates/js", version = "0.11.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } +pdfcraft-export = { path = "crates/export", version = "0.11.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } +pdfcraft-create = { path = "crates/create", version = "0.11.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } +pdfcraft-render = { path = "crates/render", version = "0.11.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } # pdf hayro = "0.7.1" diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 45641178de..c10ab74d15 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1553,13 +1553,24 @@ mod tests { ]); shared_bad.entered.wait(); shared_healthy.entered.wait(); - assert!(pool.try_recv().is_none(), "shared timeouts retry instead of answering yet"); + // This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a + // loaded machine it can finish before this same call reads the results: that early + // answer is the healthy page, never a timeout error. + let early = pool.try_recv(); + assert!( + early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()), + "shared timeouts retry instead of answering with an error: {:?}", + early.as_ref().map(|page| (page.request.page, &page.error)) + ); shared_bad.release.wait(); shared_healthy.release.wait(); shared_bad.finished.wait(); shared_healthy.finished.wait(); private_bad.entered.wait(); - let healthy = pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"); + let healthy = match early { + Some(page) => page, + None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"), + }; assert!(healthy.error.is_none(), "{:?}", healthy.error); assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41)); pool.watchdog(); diff --git a/xtask/src/version.rs b/xtask/src/version.rs index 0673653178..ed091509bc 100644 --- a/xtask/src/version.rs +++ b/xtask/src/version.rs @@ -15,12 +15,21 @@ pub fn read(manifest: &str) -> Result { Ok(v) } -/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged. +/// `manifest` with the workspace version replaced, together with the matching `version` of every +/// path dependency in `[workspace.dependencies]` (the workspace's own crates, which must agree +/// with it or `cargo update` refuses them); everything else is byte-for-byte unchanged. pub fn replace(manifest: &str, new: &str) -> Result { validate(new)?; + let old = read(manifest)?; let (idx, _) = find_line(manifest)?; + let (old_pin, new_pin) = (format!("version = \"{old}\""), format!("version = \"{new}\"")); let mut out = String::with_capacity(manifest.len() + 8); + let mut in_deps = false; for (i, line) in manifest.split_inclusive('\n').enumerate() { + let t = line.trim(); + if t.starts_with('[') { + in_deps = t == "[workspace.dependencies]"; + } if i == idx { let eol = if line.ends_with("\r\n") { "\r\n" @@ -30,6 +39,8 @@ pub fn replace(manifest: &str, new: &str) -> Result { "" }; out.push_str(&format!("version = \"{new}\"{eol}")); + } else if in_deps && line.contains("path = \"") && line.contains(&old_pin) { + out.push_str(&line.replace(&old_pin, &new_pin)); } else { out.push_str(line); } @@ -131,6 +142,18 @@ mod tests { assert!(out.contains("foo = { version = \"1\" }")); } + #[test] + fn moves_the_workspace_crates_pins_with_it() { + let manifest = "[workspace.package]\nversion = \"0.1.0\"\n\n[workspace.dependencies]\n\ + own = { path = \"crates/own\", version = \"0.1.0\" }\n\ + dep = { version = \"0.1.0\" }\n\ + other = { path = \"crates/other\", version = \"0.2\" }\n"; + let out = replace(manifest, "0.2.0").unwrap(); + assert!(out.contains("own = { path = \"crates/own\", version = \"0.2.0\" }"), "{out}"); + assert!(out.contains("dep = { version = \"0.1.0\" }"), "a registry dependency keeps its version"); + assert!(out.contains("other = { path = \"crates/other\", version = \"0.2\" }")); + } + #[test] fn keeps_crlf_line_endings() { let crlf = MANIFEST.replace('\n', "\r\n"); From 30b8c60b557a8d79e4114a984e2ca47b3a89b628 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:47:03 -0400 Subject: [PATCH 04/20] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/sonarcloud.yml diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000000..ecee45a6d8 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit From b1bf1cf01ff8d513a7a9bcb87d99933f2fb643f8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:45 -0400 Subject: [PATCH 05/20] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000000..30e80a0c5f --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_pdfcraft +sonar.organization=storytold From 322a1b2ac01a56d926406f784a48a0d5cdeda79e Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 00:33:59 +0300 Subject: [PATCH 06/20] M7: lay out the Edit panel as Acrobat's (#2245) * M7: the Edit tab turns on Edit text & images, as in Acrobat Entering Edit boxes every paragraph and image on the page at once, so text is editable with one click instead of first finding Edit text & images in the tool list. The floating toolbar gets an Edit text & images button while Edit is open or the tool is in use. Leaving Edit (or turning the tool off there) applies a paragraph still open in the editor, as clicking away from it does; left open, its page kept showing the paragraph's text removed. Co-Authored-By: Claude Opus 5.5 * M7: parity notes for the Edit tab turning on Edit text & images Co-Authored-By: Claude Opus 5.5 * M7: the panel-reveal test looks for the Edit panel's own row With Edit text & images on, its name is on the floating toolbar as well as in the panel, so the label alone matched twice and no longer showed that the Tools tab was revealed. Co-Authored-By: Claude Opus 5.5 * M7: lay out the Edit panel as Acrobat's Under the Edit tab the panel is titled Edit, with Preferences and Close; Modify page is a row of icon buttons (rotate, insert, delete, extract) and Organize pages; Add content lists Text, Image, Header and footer, Watermark and Link, with the rest under More; Other options links Combine files, Redact a PDF and Prepare a form. The milestone banner and Ready chips are gone; a planned item is muted and says when it ships. Every row still comes from the catalogue. Edit text & images is on the floating toolbar. Co-Authored-By: Claude Opus 5.5 * M7: Organize pages in the Edit panel opens the Organize workspace It ran page.organize, which showed the page grid behind the Edit panel; Acrobat replaces the panel with Organize pages' own. It now selects the Organize tool, so its panel and the grid open together. Co-Authored-By: Claude Opus 5.5 * M7: translate the Edit panel's Less and Other options Every catalogue gets the two strings the Acrobat-style Edit panel adds. Co-Authored-By: Claude Opus 5.5 * chore(i18n): refresh the catalogue hashes in ATTRIBUTION.toml Co-Authored-By: Claude Opus 5.5 * M7: no idle selection hint above Format text in the Edit panel "Shift-click to select several objects. Drag empty space to select an area." sat at the top of the Edit panel whenever Edit text & images was on, pushing Format text down; Acrobat's panel has no such line. The selection count and how to move it still show while objects are selected. Tests that read the hint as "nothing selected" check that no selection count shows instead. Co-Authored-By: Claude Opus 5.5 * M7: let auto-deref find the Edit panel's buttons (clippy) Co-Authored-By: Claude Opus 5.5 * M7: the Edit panel keeps main's Add / Update / Remove menu Main gave Header and footer, Watermark and Background a menu of Add, Update and Remove in the tool panel (#1857). This branch draws the Edit group in its own panel, so those rows ran only Add and Update / Remove were unreachable there. Both panels now share one helper for the caret and the menu. Co-Authored-By: Claude Opus 5.5 * Edit panel: Modify page buttons follow command enablement; Organize pages with no document asks for a file CodeRabbit on #2245: the four Modify page buttons were enabled from the catalogue (always Ready) while execute() refuses them on a document that forbids page assembly; they now use command_enabled. Organize pages with no document open selected a panel with no grid to show; it now runs page.organize, which asks for a file first. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.toml | 44 ++--- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.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/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.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/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/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.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/panels.rs | 210 +++++++++++++++++++---- crates/ui-egui/tests/editing.rs | 10 +- crates/ui-egui/tests/object_selection.rs | 15 +- crates/ui-egui/tests/ui.rs | 35 ++++ 27 files changed, 295 insertions(+), 63 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db3dd6124f..fbabdf6d44 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" +sha256 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" +sha256 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" +sha256 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" +sha256 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" +sha256 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" +sha256 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" +sha256 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" +sha256 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" +sha256 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" +sha256 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" +sha256 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" +sha256 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" +sha256 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" +sha256 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" +sha256 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" +sha256 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" +sha256 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" +sha256 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" +sha256 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" +sha256 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" +sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" +sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c" [[asset]] diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e10..4bb58ba8e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,3 +2711,5 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Less أقل + Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae8..584fafda3f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,3 +2397,5 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Less По-малко + Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9936325523..d347ca95d7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,3 +2508,5 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Less Méně + Other options Další možnosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d9207eb9d..cb1ffc2fd9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,3 +2435,5 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Less Weniger + Other options Weitere Optionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06fe2b540b..2bde0b50b9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,3 +2440,5 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Less Λιγότερα + Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992c..fbc9f54c98 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2570,6 +2570,8 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Less Menos + Other options Otras opciones # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9bdcf0bc8a..bde247a8d8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,3 +2434,5 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Less Moins + Other options Autres options diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d537677c7d..16efc30a0d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,3 +2433,5 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Less Kevesebb + Other options Egyéb beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 35cba7a410..c46f9cf81b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,3 +2646,5 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Less Meno + Other options Altre opzioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e845a736da..54bc546338 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,3 +2434,5 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Less 折りたたむ + Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1be..5ca11b941d 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,3 +2432,5 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Less Азырақ + Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc33044..2674dfc86b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,3 +2685,5 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Less Mažiau + Other options Kitos parinktys diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c432a6a92e..d6d5e5e93b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,3 +2410,5 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Less Mniej + Other options Inne opcje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 89ff80f9dd..ee041f4af1 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,3 +2448,5 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Less Menos + Other options Outras opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 73f21aa0d8..72fcb1ddcf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,3 +2347,5 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Less Меньше + Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc961..009131307f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,3 +2439,5 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Less Manj + Other options Druge možnosti diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47aee36f8b..80008d1912 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,3 +2343,5 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Less తక్కువ + Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608c..b185f0262e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,3 +2430,5 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Less Daha az + Other options Diğer seçenekler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7993a06185..c25eba25b7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,3 +2456,5 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Less Менше + Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6a..dbf9eb2245 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2683,6 +2683,8 @@ key Space Space Words Từ “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. @id file.word_count Đếm từ… + Less Ít hơn + Other options Tùy chọn khác # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddecc655f..a71b3f30e9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,3 +2398,5 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Less 收起 + Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649b..630bfbf34b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,3 +2384,5 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Less 收起 + Other options 其他選項 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0f2c601497..8d6660b643 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2,7 +2,7 @@ //! right-hand panels (Comments, Bookmarks, Pages, Fields, Layers, Attachments). use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2}; -use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup}; +use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup, ToolItem}; use pdfcraft_render::{DocInfo, FieldKind, OutlineItem}; use crate::theme::{self, Tokens}; @@ -144,6 +144,10 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { } fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { + if g.id == "edit" { + edit_panel(app, ui, t, g); + return; + } let (back, close) = panel_header(ui, t, g.label, true); if back { app.left = LeftPanel::AllTools; @@ -185,20 +189,6 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static }); ui.add_space(6.0); } - // Edit a PDF shows Format text at the top while text is selected or being added. - if g.id == "edit" { - if app.quick_tool == crate::QuickTool::EditText { - let count = app.active_ids().map_or(0, |(i, _)| app.views[i].objects.count()); - if count > 0 { - ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); - ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); - } else { - ui.label(tl!("Shift-click to select several objects. Drag empty space to select an area.")); - } - ui.add_space(6.0); - } - format_section(app, ui, t); - } let mut run = None; // Redact a PDF has Acrobat's footer: Clear all / Redact all. let footer = g.id == "redact"; @@ -238,31 +228,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ui.painter().rect_filled(r, CornerRadius::same(4), fill); ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg); let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)); + // An entry with further commands opens a menu; its caret goes before the chip. if item.actions.is_empty() { if resp.clicked() { run = Some(item.command); } - } else { - // A caret before the chip says the entry opens a menu (Add / Update / Remove). - let c = r.left_center() - vec2(10.0, 0.0); - ui.painter().add(egui::Shape::convex_polygon( - vec![c + vec2(-3.5, -2.0), c + vec2(3.5, -2.0), c + vec2(0.0, 2.5)], - fg, - egui::Stroke::NONE, - )); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(180.0); - for (n, id) in item.actions.iter().enumerate() { - let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; - // Add stays usable with no document open: it asks for a file first. - let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); - let label = crate::i18n::menu_label(spec.id, spec.label); - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { - run = Some(spec.id); - ui.close(); - } - } - }); + } else if let Some(cmd) = actions_menu(app, ui, &resp, item, r.left_center() - vec2(10.0, 0.0), fg) { + run = Some(cmd); } } } @@ -301,6 +273,172 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } } +/// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. +const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; +/// Add content items shown before More. +const EDIT_CONTENT_SHOWN: usize = 5; +/// Other options: the tools Acrobat's Edit panel links to. +const EDIT_OTHER_TOOLS: [&str; 3] = ["combine", "redact", "form"]; + +/// Edit a PDF, laid out as Acrobat's Edit panel: the title with Preferences and Close; Format text +/// while text is being edited or added; Modify page as a row of icon buttons and Organize pages; +/// Add content with the rarer items under More; and Other options, the neighbouring tools. +/// Edit text & images itself is on the floating toolbar, and on from the moment Edit opens. +/// Every item comes from the catalogue, so its label, icon and availability have one source. +fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { + let items = || g.sections.iter().flat_map(|s| s.items.iter()); + let item = |command: &str| items().find(|i| i.command == command); + let mut run = None; + let mut tool = None; + // Back to All tools only when Edit was opened from there; the Edit tab is its own way back. + let from_all_tools = app.mode != crate::Mode::Edit; + ui.horizontal(|ui| { + if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { + app.left = LeftPanel::AllTools; + app.mode = crate::Mode::AllTools; + } + // "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools. + let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") }; + let title_width = (ui.available_width() - 2.0 * (26.0 + ui.spacing().item_spacing.x)).max(1.0); + ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)).truncate()) + .on_hover_text(title); + if icons::button(ui, "settings", 26.0, false, tl!("Preferences")).clicked() { + run = Some("app.preferences"); + } + if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() { + app.left_open = false; + } + }); + ui.add_space(2.0); + ui.separator(); + ui.add_space(4.0); + // Several objects selected: how many, and how to move them. Acrobat's panel has no idle hint. + let count = app.active_ids().filter(|_| app.quick_tool == crate::QuickTool::EditText).map_or(0, |(i, _)| app.views[i].objects.count()); + if count > 0 { + ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); + ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); + ui.add_space(6.0); + } + // Format text at the top while text is selected or being added. + format_section(app, ui, t); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + widgets::section_title(ui, tl!("Modify page")); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + for i in EDIT_PAGE_BUTTONS.iter().filter_map(|c| item(c)) { + // Enabled as the command itself is: a document that forbids page assembly refuses these. + let enabled = pdfcraft_engine::commands::command(i.command).is_some_and(|spec| app.command_enabled(spec)); + let resp = ui.add_enabled_ui(enabled, |ui| icons::button(ui, i.icon, 34.0, false, tl!(i.label))).inner; + if resp.clicked() { + run = Some(i.command); + } + } + }); + ui.add_space(2.0); + // Organize pages is a workspace of its own: its panel replaces this one, with the grid. + if let Some(i) = item("page.organize") + && edit_row(ui, t, i.icon, tl!(i.label), i.availability).clicked() + { + // With no document there is no grid to open: the command asks for a file first. + if app.active.is_some() { + tool = Some("organize"); + } else { + run = Some("page.organize"); + } + } + + widgets::section_title(ui, tl!("Add content")); + let content: Vec<_> = + g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); + let more_id = egui::Id::new("edit_panel_more"); + let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); + let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; + for i in content.iter().take(shown) { + let resp = edit_row(ui, t, i.icon, tl!(i.label), i.availability); + // Header and footer, Watermark and Background open their Add / Update / Remove menu. + if i.actions.is_empty() { + if resp.clicked() { + run = Some(i.command); + } + } else if let Some(cmd) = actions_menu(app, ui, &resp, i, resp.rect.right_center() - vec2(14.0, 0.0), t.text) { + run = Some(cmd); + } + } + if content.len() > EDIT_CONTENT_SHOWN { + ui.add_space(4.0); + let label = if more { tl!("Less") } else { tl!("More") }; + let resp = ui + .add( + egui::Label::new(egui::RichText::new(label).color(t.accent_text).font(theme::medium(13.0))) + .selectable(false) + .sense(Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(more_id, !more)); + } + } + + widgets::section_title(ui, tl!("Other options")); + for other in EDIT_OTHER_TOOLS.iter().filter_map(|id| catalog::group(id)) { + if edit_row(ui, t, other.icon, tl!(other.label), other.availability).clicked() { + tool = Some(other.id); + } + } + }); + if let Some(id) = tool { + app.select_tool(id); + } + if let Some(cmd) = run { + app.run_command(cmd); + } +} + +/// A tool entry with further commands (Add / Update / Remove): a caret at `caret` says it opens a +/// menu, and each command is enabled by its own needs. Returns the command chosen. +fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: &ToolItem, caret: egui::Pos2, color: Color32) -> Option<&'static str> { + ui.painter().add(egui::Shape::convex_polygon( + vec![caret + vec2(-3.5, -2.0), caret + vec2(3.5, -2.0), caret + vec2(0.0, 2.5)], + color, + egui::Stroke::NONE, + )); + let mut run = None; + egui::Popup::menu(resp).show(|ui| { + ui.set_min_width(180.0); + for (n, id) in item.actions.iter().enumerate() { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + // Add stays usable with no document open: it asks for a file first. + let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); + let label = crate::i18n::menu_label(spec.id, spec.label); + if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { + run = Some(spec.id); + ui.close(); + } + } + }); + run +} + +/// One row of the Edit panel: a monochrome icon and the label, as Acrobat draws them. A planned +/// item is muted and says when it ships when hovered. +fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let press = theme::Press::track(ui, &resp); + press.wash(ui, rect, 6, false); + let body = rect.translate(press.offset()); + let ready = availability == Availability::Ready; + let fg = if ready { t.text } else { t.text_muted }; + icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), icon, 17.0, if ready { t.text } else { t.text_faint }); + row_label(ui, label, theme::regular(13.5), fg, body.left_center() + vec2(34.0, 0.0), rect.width() - 42.0); + let resp = theme::hand(resp); + match availability { + Availability::Ready => resp, + Availability::Planned(m) => resp.on_hover_text(format!("{} {m}", tl!("Planned for milestone"))), + Availability::Provider => resp.on_hover_text(tl!("Optional: needs an AI provider you configure")), + } +} + /// Add a stamp: Dynamic, Sign Here and Standard Business stamps; click one, then click on the /// page to place it. fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index d41041e52e..1890c1c320 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3256,11 +3256,17 @@ fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset: fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) { let (from, to) = paragraph_drag_points(h, text, offset); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); assert!(h.state().views[0].line_editor.is_none()); drag(h, from, to); assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor"); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); } fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) { diff --git a/crates/ui-egui/tests/object_selection.rs b/crates/ui-egui/tests/object_selection.rs index 343483378c..ceb700a1c0 100644 --- a/crates/ui-egui/tests/object_selection.rs +++ b/crates/ui-egui/tests/object_selection.rs @@ -146,7 +146,10 @@ fn marquee_and_mixed_group_drag_move_once_and_undo_together() { h.key_press_modifiers(Modifiers::COMMAND, Key::Z); h.run_steps(4); assert_eq!(objects(&h), before); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); h.run_steps(4); assert_eq!(objects(&h), after); @@ -171,7 +174,10 @@ fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); h.key_press(Key::Escape); h.run_steps(3); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); } #[test] @@ -221,7 +227,10 @@ fn a_document_change_invalidates_old_selection_before_the_next_drag() { .apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } }) .unwrap(); h.run_steps(4); - h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); assert_eq!(objects(&h).len(), 5); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index d30ab1b8dd..118dee8f74 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1425,3 +1425,38 @@ fn the_edit_tab_turns_on_edit_text_and_images() { h.run_steps(3); assert_eq!(h.state().quick_tool, QuickTool::Hand); } + +/// Organize pages in the Edit panel opens the Organize workspace: its own panel and the page grid, +/// not the grid behind the Edit panel. +#[test] +fn organize_pages_from_the_edit_panel_opens_its_panel() { + use pdfcraft_ui_egui::LeftPanel; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Edit").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::Tool("edit")); + assert!( + h.query_by_label("Shift-click to select several objects. Drag empty space to select an area.").is_none(), + "no idle hint above Format text, as in Acrobat" + ); + h.get_by_label("Organize pages").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::Tool("organize")); + assert!(h.state().views[0].organize, "the page grid is showing"); +} + +/// Header and footer, Watermark and Background keep their Add / Update / Remove menu in the Edit +/// panel, as under All tools: Update and Remove wait for the document to have that kind of mark. +#[test] +fn the_edit_panel_keeps_the_add_update_remove_menu() { + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.get_by_label("Edit").click(); + h.run_steps(3); + h.get_by_label("Watermark").click(); + h.run_steps(3); + assert_eq!(enabled(&h, "Add watermark…"), Some(true)); + assert_eq!(enabled(&h, "Remove watermark"), Some(false), "nothing to remove yet"); + h.get_by_label("Add watermark…").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Marks(pdfcraft_engine::MarkKind::Watermark))); +} From 8f3456f29c9fbcb94a46e91fd9cdf58588a323a0 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Wed, 21 Oct 2026 23:34:11 +0200 Subject: [PATCH 07/20] =?UTF-8?q?fix(engine):=20PNG=20export=20writes=20op?= =?UTF-8?q?aque=20pages=20as=20RGB=20at=20deflate=20level=203=20(3?= =?UTF-8?q?=E2=80=935=C3=97=20faster,=20same=20size)=20(#2425)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(engine): PNG export writes opaque pages as RGB at deflate level 3 `encode_png` wrote every raster as RGBA at the png crate's default level (zlib level 6). A rendered page is always opaque, so the alpha channel only made the file bigger and slower to write. An opaque raster is now written as RGB, at deflate level 3 with adaptive filtering; a raster with translucent pixels is still written as RGBA with straight alpha. The files come out the same size, 3-5x faster (release build, Linux): 300-dpi scan drawn at 220 dpi 805 -> 167 ms 4.46 -> 4.47 MB text page at 220 dpi 70 -> 40 ms 0.85 -> 0.89 MB annual-report page at 150 dpi 179 -> 64 ms 1.41 -> 1.38 MB This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and Print as Image; the automation tools (`page_render` and the other page previews, MCP included) had a copy of it and now call it instead, so png is only a dev-dependency of pdfcraft-automation. Two automation tests that read `page_render` output as RGBA now accept the RGB a page renders as. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ * fix(engine): PNG export rejects a raster of the wrong length encode_png now checks that the buffer is exactly width × height × 4 bytes (checked arithmetic) before choosing RGB or RGBA. The opaque path's as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1, &[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder returned an error. Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected covers opaque and translucent buffers with a partial pixel, a pixel short, a pixel too many, an empty buffer and a size whose byte count overflows; it fails on the previous head at the reviewer's case. Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/automation/Cargo.toml | 2 +- crates/automation/src/lib.rs | 19 +----- crates/automation/tests/automation.rs | 25 ++++--- crates/engine/src/export.rs | 96 ++++++++++++++++++++++++--- docs/roadmap.md | 2 + 6 files changed, 106 insertions(+), 39 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1283e7751..b910ce0b62 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -218,6 +218,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. +- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4c..26f36728cb 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -png = "0.18" base64 = { version = "0.22", optional = true } @@ -28,6 +27,7 @@ mcp = ["dep:base64"] [dev-dependencies] pdfcraft-fonts = { workspace = true } +png = "0.18" flate2.workspace = true pdfcraft-model = { workspace = true } pdfcraft-xfa = { workspace = true } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 0e2e0dd698..af0583717f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2506,24 +2506,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) } +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; - } - } - } - let mut out = Vec::new(); - let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); - enc.set_depth(png::BitDepth::Eight); - let mut w = enc.write_header().map_err(failed)?; - w.write_image_data(&rgba).map_err(failed)?; - w.finish().map_err(failed)?; - Ok(out) + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) } /// Extract the text of `pages` (0-based) with one renderer per worker thread. diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index cbf40f1962..f760398a1e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3880,15 +3880,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() { let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); - assert_eq!(info.color_type, png::ColorType::Rgba); - for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([ - [240, 20, 20, 255], - [20, 180, 20, 255], - [20, 20, 240, 255], - [230, 180, 20, 255], - ]) { - let offset = ((y * info.width + x) * 4) as usize; - assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}"); + // A rendered page is opaque, so it comes as RGB. + assert_eq!(info.color_type, png::ColorType::Rgb); + for ((x, y), colour) in + [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]]) + { + let offset = ((y * info.width + x) * 3) as usize; + assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}"); } } } @@ -6515,7 +6513,8 @@ fn reading_colors_workdir() -> PathBuf { dir } -/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. +/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages +/// come as RGB; their alpha is 255. fn png_pixels(content: &Content) -> Vec { let Content::Png { data, width, height } = content else { panic!("expected a PNG") }; assert_eq!((*width, *height), (200, 300)); @@ -6523,7 +6522,11 @@ fn png_pixels(content: &Content) -> Vec { let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); assert_eq!((info.width, info.height), (200, 300)); - pixels + match info.color_type { + png::ColorType::Rgba => pixels, + png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + other => panic!("unexpected colour type {other:?}"), + } } /// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image. diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 0bf4e2f79d..a1820e2d99 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -138,23 +138,48 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image } } -/// Premultiplied RGBA → PNG (straight alpha). +/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered +/// page always is one: it is drawn on white) is written as RGB. +/// +/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the +/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at +/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms +/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual +/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + // Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel, + // and the opaque path would write the rest as a valid image. + let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); + if expected != Some(premultiplied.len()) { + return Err(match expected { + Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()), + None => format!("a {width}×{height} raster is too large"), + }); + } + let pixels = premultiplied.as_chunks::<4>().0; + let opaque = pixels.iter().all(|px| px[3] == 255); + let (color, data) = if opaque { + (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>()) + } else { + let mut rgba = premultiplied.to_vec(); + for px in rgba.as_chunks_mut::<4>().0 { + let a = u32::from(px[3]); + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } } } - } + (png::ColorType::Rgba, rgba) + }; let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); + enc.set_color(color); enc.set_depth(png::BitDepth::Eight); + enc.set_deflate_compression(png::DeflateCompression::Level(3)); + enc.set_filter(png::Filter::Adaptive); let mut w = enc.write_header().map_err(|e| e.to_string())?; - w.write_image_data(&rgba).map_err(|e| e.to_string())?; + w.write_image_data(&data).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; Ok(out) } @@ -260,3 +285,54 @@ mod image_name_tests { assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } + +#[cfg(test)] +mod png_tests { + use super::encode_png; + + /// The colour type and pixels of a PNG. + fn decode(png: &[u8]) -> (png::ColorType, Vec) { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(frame.buffer_size()); + (frame.color_type, pixels) + } + + /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as + /// rendered (the alpha channel would only make the file bigger and slower to write). + #[test] + fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() { + let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]]; + let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgb); + assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>()); + } + + /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied. + #[test] + fn translucent_rasters_keep_straight_alpha() { + let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]]; + let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgba); + assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); + } + + /// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The + /// opaque path used to drop a partial trailing pixel and write the rest as a valid image. + #[test] + fn rasters_of_the_wrong_length_are_rejected() { + let cases: [(&str, u32, u32, &[u8]); 7] = [ + ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]), + ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]), + ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]), + ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]), + ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]), + ("empty", 1, 1, &[]), + ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), + ]; + for (what, width, height, data) in cases { + assert!(encode_png(width, height, data).is_err(), "{what}"); + } + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d73511e9c..ed56c8aeaa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -19,6 +19,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (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) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -222,6 +223,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | +| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | | 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | From 583c1ec68c75024d77066983a79c05f27141f820 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 00:34:16 +0300 Subject: [PATCH 08/20] M3: the mode bar has no Read tab, as in Acrobat (#2240) * M3: the mode bar has no Read tab, as in Acrobat Acrobat's mode bar is All tools, Edit, Convert and E-Sign: reading is what the viewer does with no tool in use. Read mode (the panes hidden) stays a View command and a default workspace. Tests that clicked the Read tab as an ordinary mode-bar button use Convert instead. Co-Authored-By: Claude Opus 5.5 * M3: parity notes for the mode bar without a Read tab Co-Authored-By: Claude Opus 5.5 * M3: the Read-mode tests enter through the View command The mode bar no longer has a Read tab, so `read_mode_hides_the_quick_toolbar` (ui.rs) and `switching_modes_keeps_the_current_page_at_fixed_zoom` (view.rs) clicked a button that is gone, failing the test jobs on ubuntu-latest and windows-latest. Both run `view.read_mode` now, as the other read-mode tests in this PR do; the second is the test the `view.read-mode` parity entry cites. `cargo test -p pdfcraft-ui-egui --test ui --test view`: 56 and 55 passed. --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 6 ++-- crates/ui-egui/tests/control.rs | 62 ++++++++++++++++++--------------- crates/ui-egui/tests/ui.rs | 21 +++++++++-- crates/ui-egui/tests/view.rs | 2 +- parity/acrobat-features.toml | 4 +-- 5 files changed, 57 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e01d752c40..48d1d3c44c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -500,9 +500,9 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); - for (mode, label) in - [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] - { + // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool + // in use. Read mode (the panes hidden) stays a View command and a default workspace. + for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() { app.switch_mode(mode, true); } diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index e6af166d77..20fa4cb230 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -119,26 +119,28 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { #[test] fn control_focus_traverses_real_widgets_and_keyboard_activates() { let (mut h, c) = harness(); - ok(&mut h, &c, "ui.focus", json!({"label": "Read"})); - let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"})); - assert_eq!(read["widgets"][0]["focused"], true, "{read}"); - assert_eq!(read["widgets"][0]["focusable"], true); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); + let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + let focused = + |found: &serde_json::Value, label: &str| found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label && w["focused"] == true); + assert!(focused(&convert, "Convert"), "{convert}"); + assert!(convert["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focusable"] == true)); ok(&mut h, &c, "ui.key", json!({"key": "Tab"})); - let edit = ok(&mut h, &c, "ui.inspect", json!({"query": "Edit", "role": "Button"})); - assert!(edit["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Edit" && w["focused"] == true), "{edit}"); + let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"})); + assert!(focused(&sign, "E-Sign"), "{sign}"); ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]})); - let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"})); - assert_eq!(read["widgets"][0]["focused"], true); + let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + assert!(focused(&convert, "Convert"), "{convert}"); ok(&mut h, &c, "ui.key", json!({"key": "Enter"})); let state = ok(&mut h, &c, "ui.state", json!({})); - assert_eq!(state["mode"], "Read"); + assert_eq!(state["mode"], "Convert"); assert_eq!(state["documents"][0]["dirty"], false); } #[test] fn control_button_focus_preserves_document_undo_redo_shortcuts() { let (mut h, c) = harness_pages(2); - ok(&mut h, &c, "ui.focus", json!({"label": "Read"})); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); ok(&mut h, &c, "ui.key", json!({"key": "Enter"})); ok(&mut h, &c, "ui.click", json!({"label": "All tools"})); assert!(h.ctx.egui_wants_keyboard_input()); @@ -176,9 +178,9 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() { json!({"label": "does not exist"}), json!({"id": "not a number"}), json!({"id": 0}), - json!({"id": 1, "label": "Read"}), + json!({"id": 1, "label": "Convert"}), json!({"label": false}), - json!({"label": "Read", "force": true}), + json!({"label": "Convert", "force": true}), json!([]), ] { assert!(call(&mut h, &c, "ui.focus", params).is_err()); @@ -186,9 +188,10 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() { let widgets = ok(&mut h, &c, "ui.inspect", json!({})); let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap(); assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err()); - let read = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read").unwrap(); - ok(&mut h, &c, "ui.focus", json!({"id": read["id"]})); - assert_eq!(ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}))["widgets"][0]["focused"], true); + let convert = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert").unwrap(); + ok(&mut h, &c, "ui.focus", json!({"id": convert["id"]})); + let found = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"})); + assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focused"] == true), "{found}"); // Home has no active document, so its document-only toolbar controls are disabled. ok(&mut h, &c, "ui.click", json!({"label": "Home"})); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true); @@ -740,8 +743,8 @@ fn japanese_controls_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); - ok(&mut h, &c, "ui.click", json!({ "label": "閲覧" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "変換" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); @@ -766,8 +769,8 @@ fn japanese_controls_and_search_keep_command_ids() { fn german_preferences_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" })); - ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); @@ -958,23 +961,24 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { #[test] fn inspect_and_click_by_label_and_id() { let (mut h, c) = harness(); - let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "read" })); - let read = found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read" && w["clickable"] == true).cloned().expect("a Read tab"); - let rect = read["rect"].as_array().unwrap(); + let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "convert" })); + let convert = + found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert" && w["clickable"] == true).cloned().expect("a Convert tab"); + let rect = convert["rect"].as_array().unwrap(); assert!(rect[2].as_f64().unwrap() > rect[0].as_f64().unwrap()); - ok(&mut h, &c, "ui.click", json!({ "label": "Read" })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + ok(&mut h, &c, "ui.click", json!({ "label": "Convert" })); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); let all = ok(&mut h, &c, "ui.inspect", json!({ "query": "all tools" })); let id = all["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "All tools" && w["clickable"] == true).unwrap()["id"].clone(); ok(&mut h, &c, "ui.click", json!({ "id": id })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); - // Clicking a point works too (here: the Read tab's centre). + // Clicking a point works too (here: the Convert tab's centre). let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| rect[i].as_f64().unwrap()); ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 })); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read"); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); let err = call(&mut h, &c, "ui.click", json!({ "label": "No such button" })).unwrap_err(); assert!(err.contains("no enabled clickable widget"), "{err}"); @@ -1622,10 +1626,10 @@ fn click_and_drag_refuse_coordinates_that_overflow_like_move() { let e = call(&mut h, &c, method, params.clone()).expect_err(&format!("{method} {params} is refused")); assert!(e.starts_with(&format!("{method}: ")) && e.contains("finite"), "{e}"); h.run_steps(4); - assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["count"], 1); + assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["count"], 1); } - let read = ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["widgets"][0]["rect"].clone(); - let (x, y) = (read[0].as_f64().unwrap() + 4.0, read[1].as_f64().unwrap() + 4.0); + let convert = ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["widgets"][0]["rect"].clone(); + let (x, y) = (convert[0].as_f64().unwrap() + 4.0, convert[1].as_f64().unwrap() + 4.0); assert_eq!(ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }))["clicked"], json!([x, y])); ok(&mut h, &c, "ui.drag", json!({ "from": [x, y], "to": [x + 10.0, y], "steps": 2 })); } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 118dee8f74..a74e588790 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -192,6 +192,21 @@ fn read_mode_rail_buttons_open_their_panels() { h.get_by_label("Alpha section"); } +/// The mode bar matches Acrobat's: All tools, Edit, Convert and E-Sign, with no Read tab. Read +/// mode is still a View command. +#[test] +fn the_mode_bar_has_no_read_tab() { + use pdfcraft_ui_egui::Mode; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + for label in ["All tools", "Edit", "Convert", "E-Sign"] { + assert!(h.query_all_by_label(label).next().is_some(), "the {label} tab is there"); + } + assert!(h.query_all_by_label("Read").next().is_none(), "no Read tab"); + h.state_mut().run_command("view.read_mode"); + h.run_steps(2); + assert_eq!(h.state().mode, Mode::Read); +} + /// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about /// first, as in Acrobat: "Not now" leaves the window as it is, "Full Screen" enters it. #[test] @@ -227,7 +242,7 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() { use pdfcraft_ui_egui::{Mode, RightPanel}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().right, Some(RightPanel::Comments)); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(3); assert_eq!(h.state().mode, Mode::Read); assert_eq!(h.state().right, None); @@ -254,7 +269,7 @@ fn read_mode_hides_the_quick_toolbar() { h.get_by_label("Hand (H)").click(); h.run_steps(2); assert_eq!(h.state().quick_tool, QuickTool::Hand); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(3); assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode"); assert_eq!(h.state().quick_tool, QuickTool::Select); @@ -286,7 +301,7 @@ fn a_pane_closed_in_read_mode_stays_closed_when_leaving_it() { use pdfcraft_ui_egui::{Mode, RightPanel}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().right, Some(RightPanel::Comments)); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(3); assert_eq!(h.state().right, None); // Open Comments from the rail, then close it again. diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 9cf99021a3..39ea83df76 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -1594,7 +1594,7 @@ fn switching_modes_keeps_the_current_page_at_fixed_zoom() { h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move)); h.run_steps(8); let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); - h.get_by_label("Read").click(); + h.state_mut().run_command("view.read_mode"); h.run_steps(8); assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page"); let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7aca7d5c8b..6caa46d3a2 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1094,8 +1094,8 @@ milestone = "M3" status = "shipped" commands = ["view.read_mode"] tools = [] -evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] -notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position" +evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"] +notes = "View ▸ Read mode (and the Read mode workspace) hides the panes and tool chrome. The mode bar has no Read tab, as in Acrobat, where reading is the viewer with no tool in use: its tabs are All tools, Edit, Convert and E-Sign. Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position" [[feature]] id = "view.full-screen" From d90cee4669433df2288cabbd4ac7b0894643c3ea Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 00:01:35 +0200 Subject: [PATCH 09/20] fix(render): read each object stream's index once (vendored hayro-syntax patch 22) (#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(render): read each object stream's index once (vendored hayro-syntax patch 22) `XRef::get_with` parsed an object stream's whole index of `objnum offset` pairs again for every object it fetched from that stream, so reading all of a stream of N objects took N² steps. Firefox keeps every object of a page in one stream: the pdf.js corpus file bug1978317.pdf (65,542 objects in one stream) took 37 s to draw and 40 s to extract its text, and was a known timeout in the robustness baseline. The parsed index is now kept per stream, beside the decoded stream data it indexes (`ObjectStreamIndexes`), and never cleared, as that data isn't. A malformed index is remembered as malformed; `/N` or `/First` that can't be read (a reference that can't be followed while another stream loads) is not remembered. An offset past `usize` is malformed instead of wrapping. A poisoned lock is still used: entries are inserted whole. Release build, Linux: bug1978317.pdf draws in 0.5 s (was 37 s) and extracts its text in 1.9 s (was 40 s), and leaves the baseline's known timeouts; 40,000 pages in one stream open in 0.2 s (was 54 s). `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time` times out at 20 s with the fix reverted and passes in 0.2 s with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ * test(render): an object stream offset past usize is malformed, not a panic Review follow-up on #2427. Mutation testing by mtlcl410 found that replacing patch (22)'s `checked_add` in `read_object_stream_index` with a plain `+` survived every test, although in a debug build that `+` panics on the very input it guards against (and wraps in a release build). Their test pins it: it fails with "attempt to add with overflow" under that mutant and passes on this branch. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/render/src/raster.rs | 82 ++++++++++++++++++++++ docs/roadmap.md | 2 + vendor/README.md | 2 +- vendor/hayro-syntax/src/xref.rs | 119 +++++++++++++++++++++++++++----- xtask/baselines/pdfjs.json | 1 - 6 files changed, 188 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b910ce0b62..49a8b27cc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -216,6 +216,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. +- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged. - **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged. - **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged. - **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged. diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index c10ab74d15..d4e054cf71 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -4612,6 +4612,88 @@ trailer << /Root 1 0 R >> pdf } + /// A document whose `pages` page dictionaries all live in one (uncompressed) object stream, + /// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80 + /// re f` on a 100 pt square, except the last, which is 50 × 100 pt. + fn pages_in_one_object_stream(pages: usize) -> Vec { + // 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref. + let page = |n: usize| { + let size = if n + 1 == pages { "50 100" } else { "100 100" }; + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n") + }; + let (mut index, mut body) = (String::new(), String::new()); + for n in 0..pages { + index.push_str(&format!("{} {} ", n + 5, body.len())); + body.push_str(&page(n)); + } + index.push('\n'); + let kids: Vec = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect(); + let content = "0 0 1 rg 10 10 80 80 re f"; + let mut pdf = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (num, obj) in [ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()), + (2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))), + (3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())), + ( + 4, + format!( + "<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream", + index.len(), + index.len() + body.len() + ), + ), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes()); + } + // Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages + // at their index in object 4, and the cross-reference stream itself. + let xref_num = pages + 5; + let xref_at = pdf.len(); + let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; + let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat(); + for at in &offsets { + rows.extend(row(1, *at, 0)); + } + for n in 0..pages { + rows.extend(row(2, 4, n)); + } + rows.extend(row(1, xref_at, 0)); + pdf.extend_from_slice( + format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(), + ); + pdf.extend_from_slice(&rows); + pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); + pdf + } + + /// An object stream's index of `objnum offset` pairs is read once, not for every object + /// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of + /// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to + /// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute + /// to open that way; now a fraction of a second. The last page, at the end of the index, + /// still has its own size. + #[test] + fn objects_in_one_large_object_stream_are_read_in_linear_time() { + const PAGES: usize = 40_000; + let pdf = pages_in_one_object_stream(PAGES); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let _ = tx.send((first, last)); + }); + let (first, last) = + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time"); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!((first.width, first.height), (100, 100)); + assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]); + assert!(last.error.is_none(), "{:?}", last.error); + assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset"); + } + /// A page whose content expands far beyond its compressed stream stops at the expansion budget, /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index ed56c8aeaa..c10d91d20f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,6 +11,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; 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 (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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 (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -221,6 +222,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. | | 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. | | 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. | +| 2026-10-15 | minor | M2: an object stream's index is parsed once per stream (hayro-syntax patch 22), not once per object fetched from it; a page whose 65,542 objects share one stream draws in 0.5 s instead of 37 s. Estimates unchanged. | | 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | | 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. | | 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. | diff --git a/vendor/README.md b/vendor/README.md index 824e84419e..57bdd516c7 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device spac | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time` | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 20e33a4104..673d516d21 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -289,6 +289,28 @@ impl XRef { Some(f(&mut cache)) } + /// PdfCraft patch: (hayro-syntax (22)) run `f` on this PDF's parsed object stream indexes + /// ([`ObjectStreamIndexes`]). `None` when there is none to use (the dummy xref, or a no_std + /// cache already borrowed), and the caller then parses the index without it. A poisoned lock + /// is still used: entries are complete `Arc`s inserted in one step, so a panic elsewhere + /// cannot have left one half-written. + fn with_object_stream_indexes( + &self, + f: impl FnOnce(&mut ObjectStreamIndexes) -> T, + ) -> Option { + let Inner::Some(repr) = &self.0 else { + return None; + }; + #[cfg(feature = "std")] + let mut indexes = repr + .object_stream_indexes + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + #[cfg(not(feature = "std"))] + let mut indexes = repr.object_stream_indexes.try_borrow_mut().ok()?; + Some(f(&mut indexes)) + } + fn new( data: PdfData, xref_map: XrefMap, @@ -310,6 +332,7 @@ impl XRef { trailer_data, password: password.to_vec(), dict_cache: Arc::new(Mutex::new(DictCache::default())), + object_stream_indexes: Arc::new(Mutex::new(ObjectStreamIndexes::default())), }))); // We read the trailer twice, once to determine the encryption used and then a second @@ -622,10 +645,35 @@ impl XRef { return None; } - let stream = self.get_with::>(obj_stream_id, &ctx)?; + // PdfCraft patch: (hayro-syntax (22)) the stream's index of `objnum offset` pairs + // is parsed once and kept, as its decoded data is: parsed again for every object + // fetched from it, reading all of a stream of N objects took N² steps (one + // Firefox page kept 65,542 objects in one stream and took 37 s to draw). let data = repr.data.get_with(obj_stream_id, &ctx)?; - let object_stream = ObjectStream::new(stream, data, &ctx)?; - object_stream.get(index) + let known = self + .with_object_stream_indexes(|indexes| indexes.get(&obj_stream_id).cloned()) + .flatten(); + let offsets = match known { + // A stream whose pairs don't parse stays unreadable, as it was. + Some(offsets) => offsets?, + None => { + let stream = self.get_with::>(obj_stream_id, &ctx)?; + // `/N` and `/First` may be references that can't be followed while + // another stream loads: reading them fails without being remembered. + let num_objects = stream.dict().get::(N)?; + let first_offset = stream.dict().get::(FIRST)?; + let parsed = + read_object_stream_index(num_objects, first_offset, data).map(Arc::new); + self.with_object_stream_indexes(|indexes| { + indexes + .entry(obj_stream_id) + .or_insert_with(|| parsed.clone()) + .clone() + }) + .unwrap_or(parsed)? + } + }; + ObjectStream::with_offsets(data, &ctx, offsets).get(index) } } } @@ -698,6 +746,7 @@ impl TrailerData { #[derive(Debug, Clone)] struct SomeRepr { dict_cache: Arc>, + object_stream_indexes: Arc>, data: Arc, map: Arc>, metadata: Arc, @@ -1067,34 +1116,62 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result>>>; + +/// The `(object number, offset)` pairs at the start of an object stream's decoded `data`, the +/// offsets measured from the start of `data`. `None` when the pairs don't parse. Reads no +/// further than the data holds, whatever `/N` claims. +fn read_object_stream_index( + num_objects: usize, + first_offset: usize, + data: &[u8], +) -> Option> { + let mut r = Reader::new(data); + + let mut offsets = vec![]; + + for _ in 0..num_objects { + r.skip_white_spaces_and_comments(); + // Skip object number + let obj_num = r.read_without_context::()?; + r.skip_white_spaces_and_comments(); + let relative_offset = r.read_without_context::()?; + // PdfCraft patch: (hayro-syntax (22)) an offset past `usize` is malformed (it wrapped). + offsets.push((obj_num, first_offset.checked_add(relative_offset)?)); + } + + Some(offsets) +} + struct ObjectStream<'a> { data: &'a [u8], ctx: ReaderContext<'a>, - offsets: Vec<(u32, usize)>, + offsets: Arc>, } impl<'a> ObjectStream<'a> { fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option { let num_objects = inner.dict().get::(N)?; let first_offset = inner.dict().get::(FIRST)?; + let offsets = read_object_stream_index(num_objects, first_offset, data)?; - let mut r = Reader::new(data); - - let mut offsets = vec![]; - - for _ in 0..num_objects { - r.skip_white_spaces_and_comments(); - // Skip object number - let obj_num = r.read_without_context::()?; - r.skip_white_spaces_and_comments(); - let relative_offset = r.read_without_context::()?; - offsets.push((obj_num, first_offset + relative_offset)); - } + Some(Self::with_offsets(data, ctx, Arc::new(offsets))) + } + /// PdfCraft patch: (hayro-syntax (22)) an object stream whose index is already parsed. + fn with_offsets( + data: &'a [u8], + ctx: &ReaderContext<'a>, + offsets: Arc>, + ) -> Self { let mut ctx = ctx.clone(); ctx.set_in_object_stream(true); - Some(Self { data, ctx, offsets }) + Self { data, ctx, offsets } } fn get(&self, index: u32) -> Option @@ -1369,4 +1446,12 @@ mod tests { let mut reader = Reader::new(data); assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_none()); } + + /// PdfCraft patch: (hayro-syntax (22)) an object's offset past `usize` is malformed: a plain + /// `+` there panics in a debug build and wraps in a release one. + #[test] + fn an_object_stream_offset_past_usize_is_malformed_not_a_panic() { + assert_eq!(read_object_stream_index(1, usize::MAX, b"5 1"), None); + assert_eq!(read_object_stream_index(1, 10, b"5 1"), Some(vec![(5, 11)])); + } } diff --git a/xtask/baselines/pdfjs.json b/xtask/baselines/pdfjs.json index a441ad8722..212da3373b 100644 --- a/xtask/baselines/pdfjs.json +++ b/xtask/baselines/pdfjs.json @@ -4,7 +4,6 @@ "auth-event-ef-open.pdf": "open-error", "bug1020226.pdf": "open-error", "bug1782186.pdf": "open-error", - "bug1978317.pdf": "timeout", "encrypted-attachment.pdf": "open-error", "issue15590.pdf": "open-error", "issue15893_reduced.pdf": "open-error", From 18b61e240185cff77f93ab4afbabd1deb0c28984 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 01:06:54 +0300 Subject: [PATCH 10/20] M4: Organize pages remembers its page size, as Acrobat does (#2282) The grid's size was kept per document view and reset to 100% for every document and every start. Acrobat keeps the size last chosen, observed in its Organize Pages. It is now one value for the app, saved with the other settings like Combine's zoom, and --grid-zoom sets it too. A damaged saved value is ignored or clamped to 50-300%. Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> --- crates/ui-egui/src/canvas.rs | 4 ++++ crates/ui-egui/src/lib.rs | 11 +++++++++++ crates/ui-egui/tests/editing.rs | 33 +++++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 ++-- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index eb9244a284..62b29fea80 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2433,9 +2433,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // No dialog, close prompt or palette over the page: only then does page input count. let unobstructed = !app.modal_open() && !app.palette_open; + let organize_zoom = app.organize_zoom; let view = &mut app.views[index]; if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); return; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9db9247591..34e7f4b614 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -698,6 +698,9 @@ pub struct PdfCraftApp { /// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set /// with [`PdfCraftApp::set_combine_zoom`]. pub combine_zoom: f32, + /// How large Organize pages draws its grid, for every document (1.0 = the usual; kept in + /// the settings). + pub organize_zoom: f32, /// The Combine grid's thumbnails: rendered off the UI thread, a few at a time. pub(crate) combine_thumbs: combine_grid::Thumbs, /// Images waiting for the resolution choice (released on cancel). @@ -1013,6 +1016,7 @@ impl PdfCraftApp { combine_columns: Default::default(), combine_view: Default::default(), combine_zoom: 1.0, + organize_zoom: 1.0, combine_thumbs: Default::default(), image_import: None, custom_stamps: Vec::new(), @@ -2309,6 +2313,7 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["organize_zoom"] = serde_json::json!(self.organize_zoom); v.to_string() } @@ -2331,6 +2336,10 @@ impl PdfCraftApp { if let Some(zoom) = v["combine_zoom"].as_f64() { self.set_combine_zoom(zoom as f32); } + // Clamped like any grid zoom when the grid next shows; nonsense keeps the usual size. + if let Some(zoom) = v["organize_zoom"].as_f64().map(|z| z as f32).filter(|z| z.is_finite()) { + self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end()); + } if let Ok(r) = serde_json::from_value::>(v["recent"].clone()) { // Only keep entries whose files still exist. #[cfg(not(target_arch = "wasm32"))] @@ -2641,6 +2650,8 @@ impl PdfCraftApp { return Err("grid-zoom must be between 50 and 300".into()); } v.set_grid_zoom(percent / 100.0); + // The grid's size is one for every document, as it is remembered. + self.organize_zoom = v.grid_zoom(); } ("rotate", Some(v)) => { let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 1890c1c320..cced4fe03d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1880,6 +1880,39 @@ fn page_grid_zooms_with_its_buttons_keys_and_pinch() { assert!(!dirty(&h), "zooming is not an edit"); } +/// As in Acrobat, Organize pages keeps the size last chosen: every document's grid opens at it, +/// and so does the next start of the app. +#[test] +fn page_grid_size_is_remembered_for_every_document_and_restart() { + let mut h = organize(3); + h.get_by_label("Larger pages").click(); + h.run_steps(3); + assert_eq!(grid_zoom(&h), 1.25); + h.state_mut().open_bytes("other.pdf", None, fixture(2)).expect("second fixture opens"); + h.state_mut().set_option("organize", "on").unwrap(); + h.run_steps(4); + let other = h.state().active.expect("the new document is active"); + assert_ne!(other, 0); + assert_eq!(h.state().views[other].grid_zoom(), 1.25, "the other document"); + let settings = h.state().persist(); + let h = harness(3, move |app| { + app.restore(&settings); + app.set_option("organize", "on").unwrap(); + }); + assert_eq!(grid_zoom(&h), 1.25, "after a restart"); +} + +#[test] +fn a_damaged_remembered_page_grid_size_is_ignored_or_clamped() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"organize_zoom": "huge"}"#); + assert_eq!(app.organize_zoom, 1.0); + app.restore(r#"{"organize_zoom": 40.0}"#); + assert_eq!(app.organize_zoom, 3.0); + app.restore(r#"{"organize_zoom": -2}"#); + assert_eq!(app.organize_zoom, 0.5); +} + #[test] fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() { // A long document: sharpness must not depend on how many pages there are. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6caa46d3a2..95443d57df 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2884,8 +2884,8 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"] -notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control." +evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"] +notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0; pages drawn larger than their thumbnails are rendered at the size drawn while they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control." [[feature]] id = "organize.split-every-n" From 193754502f03d05d8f03c36f8df0dfa99e861189 Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 01:11:05 +0300 Subject: [PATCH 11/20] M3: the Tools panel has no tab strip while docked alone (#2244) A "Tools" tab sat over the tool panel although nothing else shared its group; Acrobat's tool pane has no such strip. Tools docked on its own is now drawn bare. Grouped with another panel, or floating, it keeps its tab so it can be dragged away again, and its side stays a preference and a dock command. The docking test groups Inspector with Tools by command, since there is no tab to drop on. Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> --- crates/ui-egui/src/docking.rs | 7 ++++++- crates/ui-egui/tests/control.rs | 16 +++++----------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 58fd71a678..4531709046 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -396,12 +396,17 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { // Availability is a render projection: the saved group, selection and width survive Home. let _ = rendered.apply(Action::Close { panel: Panel::Inspector }); } + // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped + // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it + // to the other side stays a preference and a command. + let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_))); + let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) }; let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits( ui, &rendered, &style, |panel| crate::i18n::t(panel.label()).into(), - permissions, + render_permissions, panel_limits, |ui, panel| match panel { Panel::Canvas => { diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 20fa4cb230..7de5c59997 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1299,19 +1299,13 @@ fn docking_pointer_group_float_close_restore_and_document_commands() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); h.run_steps(4); - let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").rect().center(); - let to = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); - h.event(egui::Event::PointerMoved(from)); - h.run_steps(2); - h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); - h.run_steps(2); - for step in 1..=8 { - h.event(egui::Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); - h.run_steps(1); - } - h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command. + assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none()); + ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); h.run_steps(4); assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools)); + // Grouped, Tools has its tab again. + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools"); h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); h.run_steps(3); h.get_by_label("Float panel").click(); From 6dec57bf809a386df2d8fad89830749786e8414f Mon Sep 17 00:00:00 2001 From: abc2cb Date: Thu, 22 Oct 2026 01:26:28 +0300 Subject: [PATCH 12/20] M7: lay out Format text as Acrobat's panel (#2249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M7: lay out Format text as Acrobat's panel Format text was a row of separate combos, a Load font button, an Installed fonts combo, a swatch grid and labelled drag values. It now follows Acrobat's arrangement: the font across the panel (the page's fonts, the standard families, Load font… and Installed fonts in one list, showing the paragraph's own font by name); size and a colour swatch, then lists and an alignment flyout; bold, italic, underline, superscript and subscript; line and paragraph spacing; horizontal scale and character spacing. One panel serves an edited paragraph and added text. What PdfCraft can't apply yet (lists, superscript and subscript, paragraph spacing, spacing for added text) is shown disabled and says it is planned. Icons stay Lucide's: superscript, subscript, pilcrow and move-horizontal are added from lucide-static 1.49.0 with attribution. Eight new strings are translated in every catalogue. Co-Authored-By: Claude Opus 5.5 * M7: parity notes for the Format text panel Co-Authored-By: Claude Opus 5.5 * i18n(vi): Superscript and Subscript for Format text The Vietnamese catalogue came from main without the Format text panel's Superscript and Subscript labels; vietnamese_covers_ui_literals lists them. Co-Authored-By: Claude Opus 5.5 * M7: Format text takes any horizontal scale and character spacing again Review of #2249: the panel's preset lists narrowed what could be set to 50-200 % and -1..5 pt, where the old fields took 10-400 % and -5..50 pt. Each now has a number field over the old range again, with the list of common values beside it. Test: the_format_panel_takes_any_scale_and_character_spacing types values between the listed ones and beyond them, saves and reopens, and compares the paragraph with the engine's own rewrite. It fails without the field. Co-Authored-By: Claude Opus 5.5 * M7: Format text's rows line their controls up and fit the panel Raised in #3037: the size list sat lower than the colour swatch and the alignment button beside it. egui lays a list out from the default control height and grows it downward, so a row's shorter controls ended up off its middle. Each row is now one control height and centres what it holds, at a list's own height. The spacing rows' columns are measured from what they hold, so the second list no longer runs past the panel; the number fields' lists open from a small arrow, which leaves the field the room its value needs. Test: format_text_rows_line_up_and_fit_the_panel checks the size list is centred with the swatch and alignment button and as tall as the font list, that each spacing row shares one line, and that nothing passes the panel. It fails without the change. Co-Authored-By: Claude Opus 5.5 * Format text: choosing the family a font name reads as still changes the font CodeRabbit on #2249: a Calibri paragraph is shown as Helvetica, so picking Helvetica left look.family equal to look0.family and no family change was sent. The pick is now tracked (LineEditor::choose_family) and counts as a change in style(), font_shown(), has_changes() and the editor preview; Keep source fonts and an applied edit clear it. Co-Authored-By: Claude Sonnet 5.5 * Format text: the reusable text style follows a selection only when its edit was applied A refused UpdateContent used to leave the new style in app.text_style, so the next added text inherited formatting the selected text never received. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Jk4iYSh3mEd145niec77LF --------- Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ATTRIBUTION.md | 6 +- ATTRIBUTION.toml | 88 +++++-- assets/icons/move-horizontal.svg | 17 ++ assets/icons/pilcrow.svg | 17 ++ assets/icons/subscript.svg | 17 ++ assets/icons/superscript.svg | 17 ++ crates/ui-egui/src/content_ui.rs | 47 +--- crates/ui-egui/src/edit_text_ui.rs | 94 +++++--- crates/ui-egui/src/format_text_ui.rs | 333 +++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 6 + crates/ui-egui/src/i18n/bg.tsv | 7 + crates/ui-egui/src/i18n/cs.tsv | 7 + crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/fr.tsv | 7 + crates/ui-egui/src/i18n/hu.tsv | 7 + crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/kk.tsv | 7 + crates/ui-egui/src/i18n/lt.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 7 + crates/ui-egui/src/i18n/sl.tsv | 7 + crates/ui-egui/src/i18n/te.tsv | 7 + crates/ui-egui/src/i18n/tr.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/vi.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/icon_data.rs | 4 + crates/ui-egui/src/icons.rs | 2 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 97 ++++---- crates/ui-egui/tests/content.rs | 59 ++++- crates/ui-egui/tests/editing.rs | 60 ++++- parity/acrobat-features.toml | 8 +- 38 files changed, 863 insertions(+), 155 deletions(-) create mode 100644 assets/icons/move-horizontal.svg create mode 100644 assets/icons/pilcrow.svg create mode 100644 assets/icons/subscript.svg create mode 100644 assets/icons/superscript.svg create mode 100644 crates/ui-egui/src/format_text_ui.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 5cfee8d55a..75ebd1d7ca 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (272) +## In this repository (276) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -280,6 +280,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | +| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index fbabdf6d44..514b5c2f06 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e2ea97b4c68e13c22ef7f6f39f9a29d21af0e562005e2fdc9e7dfdf29effe0bb" +sha256 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ac0a78f36b91c005b9543e6028b78bb44949fea1bf7991bf0791d407696cd832" +sha256 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c52b9a2aa403937e70859fe60b51719c97475c24927322d7534f5da0bdb5f420" +sha256 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dcad418fa6399955adf2a8398aec307c2c73b13a37ca78ffe142e00491ef586" +sha256 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "130f0256d4ba09bc9cb6a87fc973bad96d6b155703b713341f0afead9ad6b4b3" +sha256 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d1093647d2af3d51f77a7b53f05ef1f45442128d40e696022624fbbece5bd0f0" +sha256 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "135d00bc60ef62f45ebee7c7fd83bc7433fd4054a04fb3901b2d2e8bdd2871c7" +sha256 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0cb38f42fe45831495699f81837fb0b2d5bb16b1b69153359c4376e9bc1e6e04" +sha256 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce97821a0fd6bf758e88e654f70985ed1e94c81e36f4e31ee3fc2f05ca39d99f" +sha256 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "45567329bdb7884b0b54e5c5161e6e73452d85200945f1ba3661eab1fa35da06" +sha256 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "421ef329305624ebfc661a8d9cd82131e79ec279b8be47d4baec29a226e494d9" +sha256 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "97ed35e8959777a5f15b087136e53385430241e8522c7dc72a383c72dc0a27fd" +sha256 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bc012e0dd9ad0d212a32b71bfc75a42ffcd058484aab28d173078384c9da480f" +sha256 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd611d470f40a01b4442119f7189dabbe58b4904dbce398115399f02b2ecddaa" +sha256 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1132e904bc1a2bcc8239987aa74b2fa75ca17d8eb20b5458eec7e2c075c0445a" +sha256 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11dd8250c667ca49c40b827632b2d83cad93d07a9d5a5f27b5b9d657293efb6b" +sha256 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6aa0788d784532fb67a0b589cecc27f727e6178221ee892c3de7fe8f75790607" +sha256 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ed84a08b83035bc6f4d328b916692d40140c2748d383741d04fba6e013a76bb3" +sha256 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1a5b3e9ad3f524e04bdb60e11f4a1424ca749a5ed4840e22abeaa97d24d235e8" +sha256 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "136f1e68fc4caa5e6e8fe395551bffdc9ace93bb00e2da5ba59318846494420d" +sha256 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "052ecb803601ebc547934136c936fb5527d6dc97e49cf8cfbde0aa2b1a6cb8c7" +sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "299c274d071478c49430d8dbc2a1979085fa4f8735324697e8ac0db3d3adce7c" +sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806" [[asset]] @@ -3376,3 +3376,47 @@ licence_file = "vendor/vello_common/LICENSE-MIT" kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" + +[[asset]] +path = "assets/icons/superscript.svg" +title = "Lucide icon \"superscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0" + +[[asset]] +path = "assets/icons/subscript.svg" +title = "Lucide icon \"subscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4" + +[[asset]] +path = "assets/icons/pilcrow.svg" +title = "Lucide icon \"pilcrow\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" + +[[asset]] +path = "assets/icons/move-horizontal.svg" +title = "Lucide icon \"move-horizontal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 0000000000..7be9e3af6b --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 0000000000..1a1b38b15b --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg new file mode 100644 index 0000000000..a37e9c42d1 --- /dev/null +++ b/assets/icons/subscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg new file mode 100644 index 0000000000..c7978fb095 --- /dev/null +++ b/assets/icons/superscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f77..87f4c7756b 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -4,7 +4,7 @@ //! Delete to remove it. Each change is one undoable engine edit. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -605,51 +605,6 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { } } -/// Format text (shown in the Edit panel while a text item is selected, or for new text). -/// Returns the changed style; for a selected item the caller turns it into an update. -pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { - let mut s = style.clone(); - widgets::section_title(ui, tl!("Format text")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { - for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { - ui.selectable_value(&mut s.family, f, f.label()); - } - }); - // A list rather than a drag value: every change is an undo step. - egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { - for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut s.size, size, format!("{size} pt")); - } - }); - }); - ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { - s.bold = !s.bold; - } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { - s.italic = !s.italic; - } - ui.separator(); - for (a, icon, tip) in [ - (TextAlign::Left, "align-left", tl!("Align left")), - (TextAlign::Center, "align-center", tl!("Centre")), - (TextAlign::Right, "align-right", tl!("Align right")), - (TextAlign::Justify, "align-justify", tl!("Justify")), - ] { - if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { - s.align = a; - } - } - }); - let c = s.color; - if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { - s.color = picked; - } - ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint)); - (s != *style).then_some(s) -} - /// 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 66ad98ffe6..d272480d2a 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -162,6 +162,10 @@ pub struct LineEditor { /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, + /// A standard family was picked from the font list. The family guessed from a PDF font name + /// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone + /// would miss the choice. + family_chosen: bool, /// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and /// horizontal scale (%), and what they were. pub extras: Extras, @@ -182,36 +186,42 @@ impl Default for Extras { } } -/// Underline, line spacing, character spacing and horizontal scale for the paragraph being -/// edited (under Format text). Returns `true` when something changed. -pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { - let before = *e; - ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { - e.underline = !e.underline; - } - let zero = tl!("Line spacing").to_string(); - let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; - egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { - for v in [1.0, 1.15, 1.5, 2.0] { - ui.selectable_value(&mut e.line_spacing, v, label(v)); - } - }); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Character spacing")); - ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Horizontal scale")); - ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); - }); - *e != before -} - impl LineEditor { + /// What Format text's font list shows: the chosen page font, a standard family picked + /// instead, or the paragraph's own font (its `/BaseFont` without the subset tag). + pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String { + if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) { + return face.clone(); + } + if self.look.family != self.look0.family || self.family_chosen { + return self.look.family.label().to_string(); + } + let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default(); + // "ABCDEF+Calibri" is a subset of Calibri. + let name = match base.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, + _ => base, + }; + if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() } + } + + /// Back to the paragraph's own font: no page font chosen and the family it started with. + pub(crate) fn keep_source_font(&mut self) { + self.font_resource = None; + self.look.family = self.look0.family; + self.family_chosen = false; + } + + /// A standard family picked from the font list, replacing the paragraph's font even when it + /// is the family the paragraph's font name reads as. + pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) { + self.font_resource = None; + self.look.family = family; + self.family_chosen = true; + } + pub(crate) fn has_changes(&self) -> bool { - self.text != self.original || self.look != self.look0 || self.extras != self.extras0 + self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen } /// Extra width permitted by the editor. Source containers now remain fixed. @@ -229,7 +239,7 @@ impl LineEditor { let (l, o) = (&self.look, &self.look0); let mut style = pdfcraft_engine::BlockStyle { resource: self.font_resource.clone(), - family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), + 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), @@ -267,6 +277,7 @@ impl LineEditor { pub fn applied(&mut self) { self.selection = None; self.font_resource = None; + self.family_chosen = false; self.look0 = self.look.clone(); self.extras0 = self.extras; self.original = self.text.clone(); @@ -965,6 +976,7 @@ fn open_editor( focus: true, look: look_of(l), look0: look_of(l), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }); @@ -1177,7 +1189,7 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 let Some(line) = lines.get(index) else { continue }; let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic); look.size = line.size; - if ed.look.family != ed.look0.family { + if ed.look.family != ed.look0.family || ed.family_chosen { look.family = ed.look.family; } if ed.look.bold != ed.look0.bold { @@ -1193,7 +1205,8 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32 look.size = ed.look.size; } let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0)); - let original_face = ed.look.family == ed.look0.family && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; + let original_face = + ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic; let embedded = original_face && ed.fonts.contains_key(&line.font); if embedded && let Some(name) = ed.fonts.get(&line.font) { font.family = FontFamily::Name(name.clone().into()); @@ -1474,12 +1487,31 @@ mod tests { focus: true, look: look_of(&block), look0: look_of(&block), + family_chosen: false, extras: Extras::default(), extras0: Extras::default(), }; (app, ed) } + #[test] + fn picking_the_family_the_font_name_reads_as_still_changes_the_font() { + // A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave + // `look` equal to what the paragraph had, so no family change was sent and Calibri stayed. + let (_app, mut ed) = preview_app(); + let family = ed.look0.family; + assert_eq!(ed.style().family, None, "nothing chosen yet"); + ed.choose_family(family); + assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic))); + assert!(ed.has_changes()); + assert_eq!(ed.font_shown(&[]), family.label()); + ed.keep_source_font(); + assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice"); + ed.choose_family(family); + ed.applied(); + assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now"); + } + #[test] fn a_loaded_font_is_applied_to_the_paragraph_being_edited() { // Loading a font used to only register it on the page: the paragraph kept its source diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs new file mode 100644 index 0000000000..798b844a9e --- /dev/null +++ b/crates/ui-egui/src/format_text_ui.rs @@ -0,0 +1,333 @@ +//! Format text, laid out as Acrobat's panel: the font across the panel; size, colour, lists and +//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing; +//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the +//! fonts already on its page and the spacing controls) and added text (the standard families, +//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and +//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so. +//! +//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. + +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::{AddedText, FontFamily, TextAlign}; + +use crate::edit_text_ui::Extras; +use crate::theme::Tokens; +use crate::{icons, widgets}; + +/// A font picked from the font list. The caller applies it, since keeping the paragraph's own +/// font also means restoring the family it started with. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum FontChoice { + /// The fonts the paragraph already uses. + KeepSource, + /// One of the page's fonts, by resource name. + Resource(String), + /// A standard family. + Family(FontFamily), + /// Load font…: pick a font file. + Load, + /// A font file installed on this computer. + Installed(std::path::PathBuf), +} + +/// What the font list offers and shows. +pub(crate) struct Fonts<'a> { + /// The fonts on the paragraph's page (resource, face); `None` for added text, which has the + /// standard families only. + pub sources: Option<&'a [(String, String)]>, + /// The name the list shows for the current choice. + pub shown: String, +} + +/// What the panel changed: the style (in place) and a font picked from the list. +#[derive(Default)] +pub(crate) struct Outcome { + pub changed: bool, + pub font: Option, +} + +const SIZES: [f64; 14] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0]; +const LINE_SPACINGS: [f64; 7] = [1.0, 1.15, 1.2, 1.5, 2.0, 2.5, 3.0]; +const SCALES: [f64; 8] = [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0]; +const CHAR_SPACINGS: [f64; 8] = [-1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0]; +/// A row's height: the tallest control in it (the 30 pt icon buttons). Every control is centred +/// on the row, whatever its own height, so lists, swatches and buttons line up (#3037). +const ROW: f32 = 30.0; +/// The gap between a row's controls. +const GAP: f32 = 6.0; +/// The icon naming a list or field ([`labelled_icon`]). +const LABEL_ICON: f32 = 22.0; +/// The button beside a number field that opens its list of common values: just an arrow, so the +/// field keeps the room its value needs. +const PRESET_LIST: f32 = 22.0; +/// What can be typed: horizontal scale in percent, character spacing in points. +const SCALE_RANGE: std::ops::RangeInclusive = 10.0..=400.0; +const CHAR_SPACING_RANGE: std::ops::RangeInclusive = -5.0..=50.0; + +/// A number as the lists show it: no trailing zeros ("1.2", "100", "-0.5"). +fn number(v: f64) -> String { + let s = format!("{v:.2}"); + 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 { + let before = (look.clone(), extras.as_deref().copied()); + let mut out = Outcome::default(); + widgets::section_title(ui, tl!("Format text")); + + // The font, across the panel. + let width = ui.available_width(); + egui::ComboBox::from_id_salt("format-font") + .selected_text(fonts.shown.as_str()) + .width(width) + .show_ui(ui, |ui| { + if let Some(sources) = fonts.sources { + if ui.selectable_label(false, tl!("Keep source fonts")).clicked() { + out.font = Some(FontChoice::KeepSource); + } + for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) { + if ui.selectable_label(false, face).clicked() { + out.font = Some(FontChoice::Resource(resource.clone())); + } + } + ui.separator(); + } + for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { + if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() { + out.font = Some(FontChoice::Family(f)); + } + } + if fonts.sources.is_some() { + ui.separator(); + if ui.selectable_label(false, tl!("Load font…")).clicked() { + out.font = Some(FontChoice::Load); + } + #[cfg(not(target_arch = "wasm32"))] + ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) { + Some(list) if list.is_empty() => { + ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + } + Some(list) => { + for font in list { + if ui.button(&font.face).clicked() { + out.font = Some(FontChoice::Installed(font.path)); + ui.close(); + } + } + } + None => { + ui.label(tl!("Finding document fonts…")); + } + }); + } + }) + .response + .on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available."))); + ui.add_space(4.0); + + // Size and colour | bullets, numbering and alignment. + row(ui, |ui| { + let _ = egui::ComboBox::from_id_salt("format-size") + .selected_text(number(look.size)) + .width(64.0) + .show_ui(ui, |ui| { + for size in SIZES { + ui.selectable_value(&mut look.size, size, number(size)); + } + }) + .response + .on_hover_text(tl!("Font size")); + let swatch = colour_button(ui, t, look.color); + egui::Popup::menu(&swatch).show(|ui| { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) { + look.color = picked; + ui.close(); + } + }); + let (sep, _) = ui.allocate_exact_size(vec2(9.0, 26.0), Sense::hover()); + 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 { + TextAlign::Left => "align-left", + TextAlign::Center => "align-center", + TextAlign::Right => "align-right", + TextAlign::Justify => "align-justify", + }; + let align = icons::button(ui, current, 30.0, false, tl!("Alignment")); + let align = flyout(ui, t, align); + egui::Popup::menu(&align).show(|ui| { + ui.horizontal(|ui| { + for (a, icon, tip) in [ + (TextAlign::Left, "align-left", tl!("Align left")), + (TextAlign::Center, "align-center", tl!("Centre")), + (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; + ui.close(); + } + } + }); + }); + }); + + // Bold, italic, underline, superscript, subscript. + let mut extras = extras; + row(ui, |ui| { + if icons::button(ui, "bold", 30.0, look.bold, tl!("Bold")).clicked() { + look.bold = !look.bold; + } + if icons::button(ui, "italic", 30.0, look.italic, tl!("Italic")).clicked() { + look.italic = !look.italic; + } + match extras.as_deref_mut() { + Some(e) => { + if icons::button(ui, "underline", 30.0, e.underline, tl!("Underline")).clicked() { + e.underline = !e.underline; + } + } + None => planned(ui, "underline", tl!("Underline")), + } + planned(ui, "superscript", tl!("Superscript")); + planned(ui, "subscript", tl!("Subscript")); + }); + + // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an + // icon and its control, `half` wide. + let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); + ui.add_enabled_ui(extras.is_some(), |ui| { + let mut fallback = Extras::default(); + let e = extras.as_deref_mut().unwrap_or(&mut fallback); + row(ui, |ui| { + labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); + let own = tl!("Auto").to_string(); + let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; + egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { + ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); + for v in LINE_SPACINGS { + ui.selectable_value(&mut e.line_spacing, v, number(v)); + } + }); + ui.add_space(8.0); + ui.add_enabled_ui(false, |ui| { + labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); + let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); + let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing") + .selected_text("0") + .width(half) + .show_ui(ui, |_| {}) + .response + .on_disabled_hover_text(tip); + }); + }); + // Any value the engine takes can be typed; the lists hold the common ones. + row(ui, |ui| { + let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale")); + let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" }; + number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale"))); + ui.add_space(8.0); + let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); + let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; + number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); + }); + }); + + out.changed = (look.clone(), extras.as_deref().copied()) != before; + ui.add_space(6.0); + ui.separator(); + out +} + +/// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it. +fn row(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let size = vec2(ui.available_width(), ROW); + ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = GAP; + // A list lays itself out from the default control height and grows downward, which leaves + // it below the row's middle; at a list's own height it is centred like the rest. + let list = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y; + ui.spacing_mut().interact_size.y = ui.spacing().interact_size.y.max(list); + add(ui) + }) + .inner +} + +/// A control PdfCraft doesn't have yet: its icon, disabled, saying so when hovered. +fn planned(ui: &mut egui::Ui, icon: &str, label: &str) { + let _ = ui + .add_enabled_ui(false, |ui| icons::button(ui, icon, 30.0, false, label)) + .inner + .on_disabled_hover_text(format!("{label} — {}", tl!("Planned"))); +} + +/// The small corner mark Acrobat puts on a button that opens a menu. +fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { + let r = resp.rect; + let tri = vec![r.right_bottom() + vec2(-3.0, -3.0), r.right_bottom() + vec2(-8.0, -3.0), r.right_bottom() + vec2(-3.0, -8.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE)); + resp +} + +/// An icon naming the list beside it, for screen readers too. Returns it, for the control it +/// labels. +fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; + icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); + resp.on_hover_text(label) +} + +/// A value typed in a number field: what it edits, what may be typed, and how it reads. +struct Number<'a> { + value: &'a mut f64, + range: std::ops::RangeInclusive, + speed: f64, + suffix: &'static str, +} + +/// A number field with an arrow beside it that lists common values, in `width` all told: type any +/// value in its range, or pick one. Both are named by `label`: the icon before them and its text. +fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) { + ui.scope(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix); + let height = ui.spacing().interact_size.y; + ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0); + let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1); + egui::Popup::menu(&list).show(|ui| { + for &v in presets { + if ui.selectable_label(*n.value == v, number(v)).clicked() { + *n.value = v; + ui.close(); + } + } + }); + }); +} + +/// The text colour as a square swatch that opens the palette. +fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + let fill = rect.shrink(4.0); + ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); + let stroke = if resp.hovered() { t.text } else { t.border }; + ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside); + crate::theme::hand(resp).on_hover_text(tl!("Colour")) +} + +#[cfg(test)] +mod tests { + #[test] + fn numbers_read_like_the_lists() { + assert_eq!(super::number(1.2), "1.2"); + assert_eq!(super::number(100.0), "100"); + assert_eq!(super::number(-0.5), "-0.5"); + assert_eq!(super::number(0.0), "0"); + assert_eq!(super::number(1.15), "1.15"); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 4bb58ba8e1..68413b493d 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2711,5 +2711,11 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Bulleted list قائمة نقطية + Numbered list قائمة مرقمة + Alignment المحاذاة + Superscript نص مرتفع + Subscript نص منخفض + Paragraph spacing تباعد الفقرات Less أقل Other options خيارات أخرى diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 584fafda3f..dabbc53d7d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2397,5 +2397,12 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Font Шрифт + Bulleted list Списък с водещи символи + Numbered list Номериран списък + Alignment Подравняване + Superscript Горен индекс + Subscript Долен индекс + Paragraph spacing Разстояние между абзаците Less По-малко Other options Други опции diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d347ca95d7..3d6d65bafb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2508,5 +2508,12 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Font Písmo + Bulleted list Odrážkový seznam + Numbered list Číslovaný seznam + Alignment Zarovnání + Superscript Horní index + Subscript Dolní index + Paragraph spacing Mezera mezi odstavci Less Méně Other options Další možnosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cb1ffc2fd9..916a03b94b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2435,5 +2435,12 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Font Schrift + Bulleted list Aufzählung + Numbered list Nummerierte Liste + Alignment Ausrichtung + Superscript Hochgestellt + Subscript Tiefgestellt + Paragraph spacing Absatzabstand Less Weniger Other options Weitere Optionen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2bde0b50b9..6ace4f9023 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2440,5 +2440,12 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Font Γραμματοσειρά + Bulleted list Λίστα με κουκκίδες + Numbered list Αριθμημένη λίστα + Alignment Στοίχιση + Superscript Εκθέτης + Subscript Δείκτης + Paragraph spacing Απόσταση παραγράφων Less Λιγότερα Other options Άλλες επιλογές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fbc9f54c98..07c1bfd6c0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2570,6 +2570,13 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Font Fuente + Bulleted list Lista con viñetas + Numbered list Lista numerada + Alignment Alineación + Superscript Superíndice + Subscript Subíndice + Paragraph spacing Espaciado entre párrafos Less Menos Other options Otras opciones diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bde247a8d8..59a2c74f1b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2434,5 +2434,12 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Font Police + Bulleted list Liste à puces + Numbered list Liste numérotée + Alignment Alignement + Superscript Exposant + Subscript Indice + Paragraph spacing Espacement des paragraphes Less Moins Other options Autres options diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 16efc30a0d..6fb896ea32 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2433,5 +2433,12 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Font Betűtípus + Bulleted list Felsorolás + Numbered list Számozott lista + Alignment Igazítás + Superscript Felső index + Subscript Alsó index + Paragraph spacing Bekezdéstávolság Less Kevesebb Other options Egyéb beállítások diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c46f9cf81b..e3390dfb12 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2646,5 +2646,12 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Font Carattere + Bulleted list Elenco puntato + Numbered list Elenco numerato + Alignment Allineamento + Superscript Apice + Subscript Pedice + Paragraph spacing Spaziatura paragrafi Less Meno Other options Altre opzioni diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 54bc546338..84b45a9cd5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2434,5 +2434,12 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Font フォント + Bulleted list 箇条書き + Numbered list 番号付きリスト + Alignment 配置 + Superscript 上付き + Subscript 下付き + Paragraph spacing 段落間隔 Less 折りたたむ Other options その他のオプション diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 5ca11b941d..4306b5055b 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2432,5 +2432,12 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Font Қаріп + Bulleted list Таңбалауыш тізім + Numbered list Нөмірленген тізім + Alignment Туралау + Superscript Жоғарғы индекс + Subscript Төменгі индекс + Paragraph spacing Абзац аралығы Less Азырақ Other options Басқа параметрлер diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2674dfc86b..5a5306ba07 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2685,5 +2685,11 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Bulleted list Sąrašas su ženkleliais + Numbered list Numeruotas sąrašas + Alignment Lygiuotė + Superscript Viršutinis indeksas + Subscript Apatinis indeksas + Paragraph spacing Tarpas tarp pastraipų Less Mažiau Other options Kitos parinktys diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d6d5e5e93b..2d423c2a29 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2410,5 +2410,12 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Font Czcionka + Bulleted list Lista punktowana + Numbered list Lista numerowana + Alignment Wyrównanie + Superscript Indeks górny + Subscript Indeks dolny + Paragraph spacing Odstęp między akapitami Less Mniej Other options Inne opcje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ee041f4af1..2729e2b971 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2448,5 +2448,12 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Font Fonte + Bulleted list Lista com marcadores + Numbered list Lista numerada + Alignment Alinhamento + Superscript Sobrescrito + Subscript Subscrito + Paragraph spacing Espaçamento entre parágrafos Less Menos Other options Outras opções diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 72fcb1ddcf..af7dc384b7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2347,5 +2347,12 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Font Шрифт + Bulleted list Маркированный список + Numbered list Нумерованный список + Alignment Выравнивание + Superscript Надстрочный + Subscript Подстрочный + Paragraph spacing Интервал между абзацами Less Меньше Other options Другие параметры diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 009131307f..fd00ce09e7 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2439,5 +2439,12 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Font Pisava + Bulleted list Označen seznam + Numbered list Oštevilčen seznam + Alignment Poravnava + Superscript Nadpisano + Subscript Podpisano + Paragraph spacing Razmik med odstavki Less Manj Other options Druge možnosti diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 80008d1912..f7e8444969 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2343,5 +2343,12 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Font ఫాంట్ + Bulleted list బుల్లెట్ జాబితా + Numbered list సంఖ్యల జాబితా + Alignment అమరిక + Superscript సూపర్‌స్క్రిప్ట్ + Subscript సబ్‌స్క్రిప్ట్ + Paragraph spacing పేరా అంతరం Less తక్కువ Other options ఇతర ఎంపికలు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b185f0262e..925ea2c4be 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2430,5 +2430,12 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Font Yazı tipi + Bulleted list Madde işaretli liste + Numbered list Numaralı liste + Alignment Hizalama + Superscript Üst simge + Subscript Alt simge + Paragraph spacing Paragraf aralığı Less Daha az Other options Diğer seçenekler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c25eba25b7..a64c31fe09 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2456,5 +2456,12 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Font Шрифт + Bulleted list Маркований список + Numbered list Нумерований список + Alignment Вирівнювання + Superscript Надрядковий + Subscript Підрядковий + Paragraph spacing Інтервал між абзацами Less Менше Other options Інші параметри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index dbf9eb2245..b2a99ef4d6 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2683,6 +2683,12 @@ key Space Space Words Từ “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này. @id file.word_count Đếm từ… + Bulleted list Danh sách dấu đầu dòng + Numbered list Danh sách đánh số + Alignment Căn lề + Paragraph spacing Khoảng cách đoạn + Superscript Chỉ số trên + Subscript Chỉ số dưới Less Ít hơn Other options Tùy chọn khác diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a71b3f30e9..80623decf9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2398,5 +2398,12 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Font 字体 + Bulleted list 项目符号列表 + Numbered list 编号列表 + Alignment 对齐 + Superscript 上标 + Subscript 下标 + Paragraph spacing 段落间距 Less 收起 Other options 其他选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 630bfbf34b..cd8a1051e1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2384,5 +2384,12 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Font 字型 + Bulleted list 項目符號清單 + Numbered list 編號清單 + Alignment 對齊 + Superscript 上標 + Subscript 下標 + Paragraph spacing 段落間距 Less 收起 Other options 其他選項 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 6e3f26f57c..f986cd6bf4 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -185,6 +185,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("user-round", include_bytes!("../../../assets/icons/user-round.svg")), ("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")), ("wrench", include_bytes!("../../../assets/icons/wrench.svg")), + ("move-horizontal", include_bytes!("../../../assets/icons/move-horizontal.svg")), + ("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")), + ("subscript", include_bytes!("../../../assets/icons/subscript.svg")), + ("superscript", include_bytes!("../../../assets/icons/superscript.svg")), ("x", include_bytes!("../../../assets/icons/x.svg")), ("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")), ("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bd084333bb..79254afa2f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -99,7 +99,7 @@ mod tests { #[test] fn shared_catalog_preserves_existing_icons_and_fallback_uri() { - assert_eq!(ICONS.len(), 186); + assert_eq!(ICONS.len(), 190); assert!(exists("minimize-2")); for (name, original) in ICONS { let expected = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34e7f4b614..ba4fc21fca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ mod files; pub mod fill_sign; mod fling; pub mod folders_ui; +mod format_text_ui; pub mod forms_ui; mod home; mod icon_data; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 8d6660b643..829ebad7e1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -516,50 +516,42 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { if let Some((i, _)) = app.active_ids() && let Some(ed) = app.views[i].line_editor.clone() { + use crate::format_text_ui::{FontChoice, Fonts}; let mut ed = ed; - let mut changed = false; - let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default(); - egui::ComboBox::from_id_salt("document-font-resource") - .selected_text( - ed.font_resource - .as_ref() - .and_then(|r| font_choices.iter().find(|f| &f.resource == r)) - .map_or(tl!("Keep source fonts"), |f| f.face.as_str()), - ) - .show_ui(ui, |ui| { - changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed(); - for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { - changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed(); - } - }); - if ui.button(tl!("Load font…")).clicked() { - app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); - } - #[cfg(not(target_arch = "wasm32"))] - egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| { - if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) { - if fonts.is_empty() { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); - } - for font in fonts { - if ui.selectable_label(false, &font.face).clicked() { - app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]); - } - } - } else { - ui.label(tl!("Finding document fonts…")); + let sources: Vec<(String, String)> = app + .session + .get(app.views[i].id) + .map(|d| d.text_editor_fonts(ed.page)) + .unwrap_or_default() + .into_iter() + .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 }); + let mut changed = out.changed; + match out.font { + Some(FontChoice::KeepSource) => { + ed.keep_source_font(); + changed = true; } - }); - if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - ed.look = look; - changed = true; + Some(FontChoice::Resource(resource)) => { + ed.font_resource = Some(resource); + changed = true; + } + Some(FontChoice::Family(family)) => { + ed.choose_family(family); + changed = true; + } + Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false), + #[cfg(not(target_arch = "wasm32"))] + Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]), + #[cfg(target_arch = "wasm32")] + Some(FontChoice::Installed(_)) => {} + None => {} } - changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { app.apply_line_format(i, ed, Some(ui.ctx())); } - ui.add_space(6.0); - ui.separator(); return; } let image = app.active_ids().and_then(|(i, id)| { @@ -593,21 +585,32 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { }); match selected { Some((page, index, text)) => { - if let Some(style) = crate::content_ui::format_panel(ui, t, &text) { - app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() }; - app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style) }); + let mut style = text.clone(); + added_text_format(ui, t, &mut style); + 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 }; + } } } None if app.quick_tool == crate::QuickTool::AddText => { crate::content_ui::hint(ui, t); - if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) { - app.text_style = style; - } + let mut style = app.text_style.clone(); + added_text_format(ui, t, &mut style); + app.text_style = style; } - None => return, + 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) { + 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 }); + if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { + style.family = family; } - ui.add_space(6.0); - ui.separator(); } impl PdfCraftApp { diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032eff..70e5a0dcb5 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -236,7 +236,7 @@ fn typing_moving_styling_and_deleting_added_content() { h.run_steps(2); assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected"); // Bold from the Format panel: one undoable change. - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(3); let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() }; @@ -585,3 +585,60 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } + +/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and +/// the controls PdfCraft can't apply yet present but disabled. +/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and +/// the alignment button and is as tall as the font list, each spacing row's controls share one +/// line, and nothing reaches past the font list, which spans the panel. +#[test] +fn format_text_rows_line_up_and_fit_the_panel() { + use egui::accesskit::Role; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| { + h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value) + }; + let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12")); + let middle = |r: egui::Rect| r.center().y; + for label in ["Colour", "Alignment"] { + let other = h.get_by_label(label).rect(); + assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})"); + } + assert_eq!(size.height(), font.height(), "the font and size lists are the same height"); + // Line and paragraph spacing; scale and character spacing with their lists. + let line = [combo(&h, "Auto"), combo(&h, "0")]; + assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}"); + // Each: its number field and the arrow that lists common values. + let scale_row: Vec = + ["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect(); + assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}"); + for r in &scale_row { + assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}"); + } + for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) { + assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})"); + } +} + +#[test] +fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { + use egui_kittest::kittest::NodeT; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.get_by_label("FORMAT TEXT"); + for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] { + assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled"); + } + h.get_by_label("Alignment").click(); + h.run_steps(2); + h.get_by_label("Centre").click(); + h.run_steps(2); + assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center); + h.get_by_label("Bold").click(); + h.run_steps(2); + assert!(h.state().text_style.bold); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index cced4fe03d..6885a2bb6d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3028,7 +3028,7 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(h.state().views[0].line_editor.is_some()); // The Format text panel shows the paragraph's look; making it bold applies at once. h.get_by_label("FORMAT TEXT"); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(4); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); @@ -3044,6 +3044,62 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } +/// Horizontal scale and Character spacing take any value in their ranges, not only the listed +/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved +/// file as the engine writes them. +#[test] +fn the_format_panel_takes_any_scale_and_character_spacing() { + let mut h = harness(1, |_| {}); + let id = h.state().views[0].id; + let width = |h: &Harness<'static, PdfCraftApp>, view: usize| { + let s = h.state(); + let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect; + r[2] - r[0] + }; + let plain = width(&h, 0); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + 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()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some()); + // Typed the way a person does: click the field, type, Enter. + let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| { + h.get_by_role_and_label(Role::SpinButton, label).click(); + h.run_steps(2); + h.event(egui::Event::Text(value.into())); + h.run_steps(1); + h.key_press(Key::Enter); + h.run_steps(4); + }; + let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing)); + // Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed. + type_in(&mut h, "Horizontal scale", "80"); + assert_eq!(extras(&h), Some((80.0, 0.0))); + type_in(&mut h, "Horizontal scale", "250"); + assert_eq!(extras(&h), Some((250.0, 0.0))); + type_in(&mut h, "Character spacing", "0.25"); + assert_eq!(extras(&h), Some((250.0, 0.25))); + type_in(&mut h, "Character spacing", "-2"); + assert_eq!(extras(&h), Some((250.0, -2.0))); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text")); + // Saved and reopened, the paragraph is as wide as the engine draws it with those values. + let bytes = h.state().session.save_bytes(id).unwrap(); + h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens"); + let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style })); + h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens"); + h.run_steps(3); + let (reference, saved) = (width(&h, 1), width(&h, 2)); + assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}"); + assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}"); +} + #[test] fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { let mut h = harness(1, |_| {}); @@ -3062,7 +3118,7 @@ fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() { h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); h.run_steps(2); assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6)); - h.get_by_label("B").click(); + h.get_by_label("Bold").click(); h.run_steps(5); let id = h.state().views[0].id; let doc = h.state().session.get(id).unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 95443d57df..42bbe8352f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1986,8 +1986,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"] -notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped" +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] +notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" [[feature]] id = "edit.lists" @@ -2592,8 +2592,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open" +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"] +notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." [[feature]] id = "edit.adobe-express-edits" From 6898094e773b41249cd20ac76754fb83c9f46986 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 01:25:43 +0200 Subject: [PATCH 13/20] M6: renaming a field renames it in the calculations that read it (#3550) * M6: renaming a field renames it in the calculations that read it A rename silently broke every formula that used the field: a sum dropped it, notation read it as 0 and a script kept a stale value. set_props now rewrites the name in each field's Calculate script, in the same edit: AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation) and getField calls naming it in one string literal. The rest of each script is kept as written; names built at run time, non-UTF-8 script streams and a name another field still has are left alone, and a self-check keeps a script whose meaning would change. Notation is read by one lexer shared by notation_js, eval_notation and the rename. form_set_props returns formulas_updated; Field Properties says how many calculations now use the new name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form_set_props leaves the renamed field out of formulas_updated by object The renamed field was matched by the name given, but set_props stores it trimmed and under its parent's prefix. When the same call changed the field's own formula, formulas_updated listed the field as one the rename had rewritten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +-- Cargo.lock | 1 + ROADMAP.md | 2 + crates/automation/src/forms.rs | 12 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 54 +++ crates/engine/src/lib.rs | 3 +- crates/forms/Cargo.toml | 1 + crates/forms/README.md | 7 + crates/forms/src/af.rs | 469 +++++++++++++++++++++++--- crates/forms/src/author.rs | 36 ++ crates/forms/src/lib.rs | 11 + crates/forms/src/tests.rs | 78 +++++ crates/ui-egui/src/dialogs.rs | 27 +- crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.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/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.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/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.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/tests/prepare.rs | 39 +++ docs/roadmap.md | 3 +- parity/acrobat-features.toml | 4 +- 39 files changed, 732 insertions(+), 83 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 514b5c2f06..361a453ecc 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "467f1e5b55c0f036b35bb72b8a23c7cd0a8bd96ae188d742393b7dc0635166df" +sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4a9d4428c3f67e256aa2f9e9d2d057c0e40f60a4446a0b384f3055894a9a9213" +sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f22298173d1e174feb83f6fb20e7079bb9091df86f396c69648b974f429be158" +sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e320d5f50795dc1c46d589d72c7a27facb6164938cf9d75151e904b7450b142d" +sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0dd7e0e0b0843b519cebc5504ce7006e4c9cb1eef02b270ed52d008972ee7ff3" +sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dda5b3aea60d718eb2fe28e02a76728a3c895487e03f9319c2bcdef7ea2012db" +sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3351f6abd5e2796ad5e70b00604c9a7093387243ad1951713a717d06bffe419d" +sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2595a893a3cb84b6f0344b9e2144d9e956e80f778eb7d045fa2ad503e3cfb267" +sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ec2d2afca09390c809e805a52108f5e8c94bf61feef7d0ba2901723db747323d" +sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "683f515a21dbc4b22b312c63e39b4d2a572a1f1cf733f02127f1ee62dbb7a05e" +sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e31d3969393994c33b90cd5fa82e80584955e4e0676df37136e345db476623ed" +sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fee17b5090aba63d3129772c271d9802444918fe3a905734b41e006e320e9234" +sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ef26690aeec1c4b6b648ac50bc2433fec681524ab98e6270cabd3d76be6d3e14" +sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2452a0f56f455ad71cae439a2ecf6da0ae5e2aaa90790de4d16e222393030283" +sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bbf8c454edc80ddeaecce63e56f5bceba335ae6cce8e4456b14742cd3a820bfe" +sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "64d3c1c532ecb94b44b2c894acdaf171a130cbaa8e173b099f9b9b249d2134f4" +sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9e87a8f108b49213c7bb0f077a2e2f1bae449e1459a908d8202beba280d8055c" +sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "921e8e906bdc42675fe8922fc00835a255e019ed61688a01095367f6e881f187" +sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "59070be5688d7d183a7354f08f9604e8f962dcc5e223dd91d1cd940997c47399" +sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f48accb38d4c824cb3d2f71fcffe0d8edb0a9a5f7412b9c479da6fb2479e45d2" +sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "42212a46ddca212bb94c15062be3b444b33181ea68cdd9880fc5191f56d986a5" +sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fcc38245d923c4ddefb5560fe7fdfe5bc6c1b9c49d4fcb25f2873e63dff4f806" +sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index e9505abbc0..449b1f82b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4177,6 +4177,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "proptest", "thiserror 2.0.21", ] diff --git a/ROADMAP.md b/ROADMAP.md index 49a8b27cc9..4d84708532 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index f2eab5ddc0..bd70b65431 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -386,12 +386,24 @@ impl Automation { } else { edits.pop().ok_or_else(|| bad("give a field name"))? }; + // A rename also renames the field in the formulas that read it. + let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None }; let mut out = self.apply(a, edit)?; if bulk { out["fields"] = json!(names); } else { out["field"] = json!(new_name); } + if let Some(before) = before { + let after = &self.doc(a)?.form; + // The renamed field itself, found by object: the name stored can differ from the + // argument (trimmed, kept under its parent's prefix). + let own = + names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name); + let mut updated = pdfcraft_engine::form_calculations_changed(&before, after); + updated.retain(|f| Some(f) != own); + out["formulas_updated"] = json!(updated); + } Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e154e1eebc..597882d2f5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -374,7 +374,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), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. 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 caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. 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(schema( json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index f760398a1e..91a2656f48 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3799,6 +3799,60 @@ fn form_formats_and_calculations_through_tools() { )); } +/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation +/// read it as 0 and a script kept a stale value. +#[test] +fn renaming_a_field_keeps_the_formulas_that_read_it() { + let dir = workdir("formrename"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "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 })); + } + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } })); + let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to })); + assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"])); + assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"])); + assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"])); + assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } })); + let values = |a: &mut Automation| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()); + [value("sum"), value("product"), value("double"), value("grand")] + }; + assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone(); + assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"])); + assert_eq!(field("product")["calculate"]["notation"], "price * d"); + // The rename and the formulas it changed are one undo step: undo the fill, then the last + // three renames. + for _ in 0..4 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned(); + assert!(field("c").is_some() && field("price").is_none()); + assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename"); + // The renamed field's own formula, changed in the same call, is not one the rename + // rewrote, though the name is stored trimmed. + let r = ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }), + ); + assert_eq!(r["formulas_updated"], json!([])); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfe11b25c6..cdc7eb646f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,7 +58,8 @@ pub use pdfcraft_edit::{ pub use pdfcraft_forms::{ BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, + appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed, + flags as field_flags, }; pub use pdfcraft_a11y as a11y; diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml index 3f085ee6cc..fcecbb5f87 100644 --- a/crates/forms/Cargo.toml +++ b/crates/forms/Cargo.toml @@ -17,3 +17,4 @@ thiserror = { workspace = true } [dev-dependencies] hayro-syntax = { workspace = true } +proptest = { workspace = true } diff --git a/crates/forms/README.md b/crates/forms/README.md index 66fb919fe7..2ba38097ed 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?; Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +- Renaming a field renames it in every Calculate script that reads it + (`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation + (the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as + one string literal. The rest of each script is kept as written. Names built at run time, a + script stream that isn't UTF-8, other events' scripts and a name another field still has are + left alone. `calculations_changed` compares two readings of a form to say which formulas an + edit rewrote. - `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears that label, independently of the field name. The index is 0-based. A label-only edit redraws diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index c5b81719ac..ab7fc327c9 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -11,6 +11,7 @@ use std::collections::VecDeque; use std::fmt::Write as _; +use std::ops::Range; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] @@ -453,6 +454,250 @@ pub fn calculate_js(c: &Calculate) -> Option { } } +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell +/// code from strings, comments and regular expressions. The tokens stop where a string, comment +/// or template literal never ends. +fn js_tokens(js: &str) -> Vec<(Range, Js)> { + let s = js.as_bytes(); + // Bytes past ASCII are parts of identifiers, so a range never ends inside a character. + let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii(); + let mut out: Vec<(Range, Js)> = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + i += n + 4; + continue; + } + None => break 'scan, + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None | Some(b'\n') => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while s.get(i).is_some_and(|b| word(*b)) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while s.get(i).is_some_and(|b| word(*b) || *b == b'.') { + i += 1; + } + Js::Other + } + _ if word(c) => { + while s.get(i).is_some_and(|b| word(*b)) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + out.push((start..i, token)); + } + out +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket or a keyword such as `return`, not after a value. +fn regex_may_follow(js: &str, tokens: &[(Range, Js)]) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), +} + +impl Site { + fn names(&self) -> Vec<&str> { + 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(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation between +/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), 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. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) + && let Some(expr) = js.get(a + 6..b) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name))); + } + } + } + let tokens = js_tokens(js); + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + 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, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + 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 + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + k + 7 + } else { + 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, + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (got == expected).then_some(out) +} + // ── running them ──────────────────────────────────────────────────────────────────────────── /// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a @@ -960,77 +1205,107 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> { }) } -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let mut out = String::new(); - let cs: Vec = expr.chars().collect(); - let mut i = 0; - while let Some(&c) = cs.get(i) { - if c.is_whitespace() || "+-*/()".contains(c) { - out.push(c); - i += 1; +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); } else if c.is_ascii_digit() || c == '.' { - while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') { - out.push(d); - i += 1; + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); } + out.push((start..end, Notation::Number)); } else { let mut name = String::new(); - while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) { - // `\` escapes a space or an operator inside a name, as Acrobat writes them. + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); if n == '\\' - && let Some(&next) = cs.get(i + 1) + && let Some((j, next)) = cs.next() { + end = j + next.len_utf8(); name.push(next); - i += 2; - continue; + } else { + name.push(n); } - name.push(n); - i += 1; } - out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name))); + out.push((start..end, Notation::Name(name))); } } out } -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names -/// (`\` escapes spaces and operators in names, as Acrobat writes them). +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { #[derive(Debug, Clone, PartialEq)] enum T { N(f64), Op(char), } - let mut toks = Vec::new(); - let cs: Vec = src.chars().collect(); - let mut i = 0; - while i < cs.len() { - let c = cs[i]; - if c.is_whitespace() { - i += 1; - } else if "+-*/()".contains(c) { - toks.push(T::Op(c)); - i += 1; - } else if c.is_ascii_digit() || c == '.' { - let s = i; - while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') { - i += 1; - } - toks.push(T::N(cs[s..i].iter().collect::().parse().ok()?)); - } else { - let mut name = String::new(); - while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) { - if cs[i] == '\\' && i + 1 < cs.len() { - i += 1; - } - name.push(cs[i]); - i += 1; - } - toks.push(T::N(value_of(&name))); - } - } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; // Recursive descent, `d` levels deep (at most MAX_NESTING). fn expr(t: &[T], i: &mut usize, d: usize) -> Option { let mut v = term(t, i, d)?; @@ -1163,6 +1438,98 @@ mod tests { ); } + #[test] + fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + // The field lists Acrobat and other tools write for AFSimple_Calculate. + assert_eq!( + renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), + Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") + ); + assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); + // Notation: the expression in the comment and Acrobat's translation after it. + let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); + assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); + assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); + // Scripts: a getField whose argument is one string literal. Comments, strings, template + // literals, regular expressions and names built at run time are left as written. + let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ + if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; + assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); + assert_eq!(renamed("event.value = getField(\"b\").value;"), None); + // A new name is escaped as the script needs it. + assert_eq!( + rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), + Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") + ); + // A name that would end the notation early is not written in. + assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); + // Unfinished scripts end their tokens, not the program. + for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { + assert_eq!(renamed(js), None, "{js}"); + } + } + + /// 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] + fn hostile_scripts_are_read_in_one_pass() { + let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); + assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); + let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); + assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); + } + + /// Notation is read through one lexer: field names with escapes, numbers and operators. + #[test] + fn notation_is_read_by_one_lexer() { + let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); + assert_eq!( + tokens, + [ + Notation::Op('('), + Notation::Name("Unit Price".into()), + Notation::Op('+'), + Notation::Number, + Notation::Op(')'), + Notation::Op('*'), + Notation::Name("qty_2\\".into()) + ] + ); + assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); + let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; + assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); + assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); + assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); + } + + proptest::proptest! { + /// Any field name written in notation reads back as that one name. + #[test] + fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { + let written = notation_name(&name); + proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); + } + + /// Renaming a field in a formula and back gives the formula as it was. + #[test] + fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { + proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); + let names: Vec = std::iter::once(old.clone()).chain(others).collect(); + let js = if sum { + calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() + } else { + calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() + }; + let renamed = rename_in_calculation(&js, &old, &new); + proptest::prop_assert!(renamed.is_some(), "{js}"); + let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); + proptest::prop_assert_eq!(back, Some(js)); + } + } + #[test] fn numbers_format_like_acrobat() { let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 521d1f5278..b4fddb589d 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -927,6 +927,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result<(), FormError> { + let aa_of = + |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + for f in all { + // The script is the field's, or its only widget's when the field has none, as + // `actions_of` reads it. + let only_widget = || match f.widgets.as_slice() { + [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), + _ => None, + }; + let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; + let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { + Some(Object::String(s)) => s.to_text(), + // A stream that isn't UTF-8 reads with replacement characters, which must not be + // written back in place of what it holds. + Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(js) => js, + None => continue, + }, + _ => continue, + }; + let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; + action.set(b"JS".to_vec(), PdfString::text(&js)); + aa.set(b"C".to_vec(), Object::Dict(action)); + doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} + /// Delete a field: its widgets leave their pages and the field leaves the form. pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { let all = fields(doc); diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 1dfa767262..5647d32c4f 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -970,6 +970,17 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result Ok(changed) } +/// The fields whose calculation differs between two readings of a form, matched by field object +/// and named as in `after`: the formulas an edit rewrote, such as a rename's. +pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { + let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); + after + .iter() + .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) + .map(|f| f.name.clone()) + .collect() +} + fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index fef5bcfbd8..f8410bf896 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1048,6 +1048,84 @@ fn formats_validation_and_calculations_run_like_acrobat() { assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } +/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation +/// read it as 0 and a script read a field that no longer existed. +#[test] +fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "product", "script"].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!["a".into(), "b".into()] })).unwrap(); + set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let check = |doc: &Document| { + let all = fields(doc); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] }); + assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into())); + let script = field(&all, "script").actions.scripts.calculate.clone(); + assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;")); + }; + check(&doc); + let mut doc = reopen(&doc); + check(&doc); + set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + let all = fields(&doc); + assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); +} + +/// The rename reaches a script stored as a stream, an indirect action and the only widget's +/// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. +#[test] +fn a_rename_reaches_every_stored_form_of_a_calculation() { + let widget = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", 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 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + widget("a", 700, ""), + widget("stream", 660, "/AA << /C 8 0 R >>"), + widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"), + "<< /S /JavaScript /JS 9 0 R >>".into(), + "<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(), + "<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(), + widget("dup", 580, ""), + widget("dup", 540, ""), + widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"), + "<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(), + ]; + let mut doc = document(&objs); + let calc = |doc: &Document, name: &str| { + let all = fields(doc); + let f = field(&all, name); + (f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default()) + }; + assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream")); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;"); + assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;"); + assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back"); + assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }); + let mut doc = doc; + set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap(); + assert_eq!( + calc(&doc, "both").0, + af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }, + "the other dup keeps the name" + ); +} + #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 981db214b2..8bff5ca8d7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1330,12 +1330,29 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if field_props_now && let Some(d) = app.field_props.take() && let Some(props) = d.props() - && app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) }) - && let Some(i) = app.active { - // Keep the (possibly renamed) field selected. - let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - app.views[i].prepare.selected = Some((format!("{prefix}{}", d.name.trim()), d.widget)); + let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone()); + // A rename also renames the field in the formulas that read it. + let before = props.name.as_ref().and_then(|_| form(app)); + if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) }) + && let Some(i) = app.active + { + // Keep the (possibly renamed) field selected. + let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + let name = format!("{prefix}{}", d.name.trim()); + if let (Some(before), Some(after)) = (before, form(app)) { + let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); + if n > 0 { + app.notify(crate::i18n::trn( + crate::i18n::current(), + n as u64, + "{n} calculation now uses the new name.", + "{n} calculations now use the new name.", + )); + } + } + app.views[i].prepare.selected = Some((name, d.widget)); + } } if props_now && let Some(d) = app.comment_props.take() { let mut edits = crate::comment_props::edits(&d); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 68413b493d..12e9293268 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2362,6 +2362,7 @@ animation effects Off معطّلة no unused output name لا يوجد اسم ناتج غير مستخدم no output folder was chosen لم يتم اختيار مجلد للإخراج @plural Downloaded {n} PDF|Downloaded {n} PDFs تم تنزيل {n} من ملفات PDF|تم تنزيل ملف PDF واحد ({n})|تم تنزيل ملفي PDF ({n})|تم تنزيل {n} ملفات PDF|تم تنزيل {n} ملف PDF|تم تنزيل {n} من ملفات PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. تم تحديث {n} من العمليات الحسابية لتستخدم الاسم الجديد.|عملية حسابية واحدة ({n}) تستخدم الآن الاسم الجديد.|عمليتان حسابيتان ({n}) تستخدمان الآن الاسم الجديد.|{n} عمليات حسابية تستخدم الآن الاسم الجديد.|{n} عملية حسابية تستخدم الآن الاسم الجديد.|{n} من العمليات الحسابية تستخدم الآن الاسم الجديد. 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 dabbc53d7d..4d6ae90a4e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2304,6 +2304,7 @@ animation effects Off Изключени no unused output name няма свободно име за изходния файл no output folder was chosen не е избрана папка за изхода @plural Downloaded {n} PDF|Downloaded {n} PDFs Изтеглен е {n} PDF|Изтеглени са {n} PDF файла +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} изчисление вече използва новото име.|{n} изчисления вече използват новото име. 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/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3d6d65bafb..8b0dd03fe9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2415,6 +2415,7 @@ animation effects Off Vypnuto no unused output name žádný volný název výstupního souboru no output folder was chosen nebyla vybrána výstupní složka @plural Downloaded {n} PDF|Downloaded {n} PDFs Stažen {n} soubor PDF|Staženy {n} soubory PDF|Staženo {n} souborů PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} výpočet nyní používá nový název.|{n} výpočty nyní používají nový název.|{n} výpočtů nyní používá nový název. 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 916a03b94b..5274c8c745 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2342,6 +2342,7 @@ animation effects Off Aus no unused output name kein freier Ausgabename no output folder was chosen kein Ausgabeordner gewählt @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF heruntergeladen|{n} PDFs heruntergeladen +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} Berechnung verwendet jetzt den neuen Namen.|{n} Berechnungen verwenden jetzt den neuen Namen. 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 6ace4f9023..f3abed3047 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2338,6 +2338,7 @@ animation effects Off Ανενεργά no unused output name δεν υπάρχει διαθέσιμο όνομα εξόδου no output folder was chosen δεν επιλέχθηκε φάκελος εξόδου @plural Downloaded {n} PDF|Downloaded {n} PDFs Λήφθηκε {n} PDF|Λήφθηκαν {n} PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} υπολογισμός χρησιμοποιεί πλέον το νέο όνομα.|{n} υπολογισμοί χρησιμοποιούν πλέον το νέο όνομα. @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 07c1bfd6c0..3f1bee4bac 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2470,6 +2470,7 @@ animation effects Off Desactivados no unused output name no hay un nombre de salida libre no output folder was chosen no se eligió ninguna carpeta de salida @plural Downloaded {n} PDF|Downloaded {n} PDFs Se descargó {n} PDF|Se descargaron {n} PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo usa ahora el nuevo nombre.|{n} cálculos usan ahora el nuevo nombre. 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/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 59a2c74f1b..5717ae203e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2341,6 +2341,7 @@ animation effects Off Désactivés no unused output name aucun nom de sortie libre no output folder was chosen aucun dossier de sortie choisi @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF téléchargé|{n} PDF téléchargés +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcul utilise désormais le nouveau nom.|{n} calculs utilisent désormais le nouveau nom. 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/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6fb896ea32..4d9b65860d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2340,6 +2340,7 @@ animation effects Off Ki no unused output name nincs szabad kimeneti név no output folder was chosen nincs kiválasztva kimeneti mappa @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF letöltve|{n} PDF letöltve +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} számítás mostantól az új nevet használja.|{n} számítás mostantól az új nevet használja. 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/it.tsv b/crates/ui-egui/src/i18n/it.tsv index e3390dfb12..74771c264e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2350,6 +2350,7 @@ animation effects Off Disattivati no unused output name nessun nome di output libero no output folder was chosen nessuna cartella di output scelta @plural Downloaded {n} PDF|Downloaded {n} PDFs Scaricato {n} PDF|Scaricati {n} PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcolo ora usa il nuovo nome.|{n} calcoli ora usano il nuovo nome. 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 84b45a9cd5..9b9ed9a928 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2341,6 +2341,7 @@ animation effects Off オフ no unused output name 使用可能な出力名がありません no output folder was chosen 出力フォルダーが選択されていません @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} 個の PDF をダウンロードしました +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 件の計算式を新しい名前に更新しました。 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 4306b5055b..43d0a9e4fc 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2328,6 +2328,7 @@ animation effects Off Өшірулі no unused output name бос шығыс атауы жоқ no output folder was chosen шығыс қалтасы таңдалмады @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF жүктелді|{n} PDF жүктелді +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} есептеу енді жаңа атауды пайдаланады.|{n} есептеу енді жаңа атауды пайдаланады. @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/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 5a5306ba07..3519dddd26 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2598,6 +2598,7 @@ certificate SHA-256 digest SHA-256 santrauka @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} lauko pavadinimas sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadintas: {list}|{n} laukų pavadinimai sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinti: {list}|{n} laukų pavadinimų sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinta: {list} @plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų @plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} skaičiavimas dabar naudoja naują pavadinimą.|{n} skaičiavimai dabar naudoja naują pavadinimą.|{n} skaičiavimų dabar naudoja naują pavadinimą. 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 2d423c2a29..fc7f9500bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2309,6 +2309,7 @@ animation effects Off Wyłączone no unused output name brak wolnej nazwy pliku wyjściowego no output folder was chosen nie wybrano folderu wyjściowego @plural Downloaded {n} PDF|Downloaded {n} PDFs Pobrano {n} plik PDF|Pobrano {n} pliki PDF|Pobrano {n} plików PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} obliczenie używa teraz nowej nazwy.|{n} obliczenia używają teraz nowej nazwy.|{n} obliczeń używa teraz nowej nazwy. 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 2729e2b971..36c9c6b816 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2355,6 +2355,7 @@ animation effects Off Desativados no unused output name nenhum nome de saída disponível no output folder was chosen nenhuma pasta de saída foi escolhida @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF baixado|{n} PDFs baixados +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo agora usa o novo nome.|{n} cálculos agora usam o novo nome. 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/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index af7dc384b7..eee29a7121 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2254,6 +2254,7 @@ animation effects Off Выкл no unused output name нет свободного имени для выходного файла no output folder was chosen не выбрана папка для вывода @plural Downloaded {n} PDF|Downloaded {n} PDFs Скачан {n} PDF-файл|Скачано {n} PDF-файла|Скачано {n} PDF-файлов +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} вычисление теперь использует новое имя.|{n} вычисления теперь используют новое имя.|{n} вычислений теперь используют новое имя. 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 fd00ce09e7..a6793e5e0f 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2345,6 +2345,7 @@ animation effects Off Izklopljeni no unused output name ni prostega izhodnega imena no output folder was chosen izhodna mapa ni bila izbrana @plural Downloaded {n} PDF|Downloaded {n} PDFs Prenesen {n} PDF|Prenesena {n} PDF-ja|Preneseni {n} PDF-ji|Prenesenih {n} PDF-jev +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} izračun zdaj uporablja novo ime.|{n} izračuna zdaj uporabljata novo ime.|{n} izračuni zdaj uporabljajo novo ime.|{n} izračunov zdaj uporablja novo ime. @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/te.tsv b/crates/ui-egui/src/i18n/te.tsv index f7e8444969..a81c5ad5e9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2242,6 +2242,7 @@ animation effects Off ఆఫ్ no unused output name ఉపయోగించని అవుట్‌పుట్ పేరు లేదు no output folder was chosen అవుట్‌పుట్ ఫోల్డర్ ఎంచుకోలేదు @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF డౌన్‌లోడ్ చేయబడింది|{n} PDFలు డౌన్‌లోడ్ చేయబడ్డాయి +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} గణన ఇప్పుడు కొత్త పేరును ఉపయోగిస్తుంది.|{n} గణనలు ఇప్పుడు కొత్త పేరును ఉపయోగిస్తాయి. 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 925ea2c4be..71f8754cbc 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2326,6 +2326,7 @@ animation effects Off Kapalı no unused output name kullanılmayan çıktı adı yok no output folder was chosen çıktı klasörü seçilmedi @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF indirildi +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} hesaplama artık yeni adı kullanıyor. @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 a64c31fe09..e44127cc80 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2363,6 +2363,7 @@ animation effects Off Вимкнено no unused output name немає вільної назви для вихідного файлу no output folder was chosen не вибрано теку для виводу @plural Downloaded {n} PDF|Downloaded {n} PDFs Завантажено {n} PDF-файл|Завантажено {n} PDF-файли|Завантажено {n} PDF-файлів +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} обчислення тепер використовує нову назву.|{n} обчислення тепер використовують нову назву.|{n} обчислень тепер використовують нову назву. 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 b2a99ef4d6..32b34de010 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2588,6 +2588,7 @@ certificate SHA-256 digest Giá trị băm SHA-256 @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} tên trường trùng với các tệp trước và sẽ được đổi tên: {list} @plural {n} object selected|{n} objects selected Đã chọn {n} đối tượng @plural Downloaded {n} PDF|Downloaded {n} PDFs Đã tải xuống {n} PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} phép tính giờ dùng tên mới. 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 80623decf9..3ec20a88ed 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2305,6 +2305,7 @@ animation effects Off 关 no unused output name 没有可用的输出文件名 no output folder was chosen 未选择输出文件夹 @plural Downloaded {n} PDF|Downloaded {n} PDFs 已下载 {n} 个 PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 个计算已改用新名称。 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 cd8a1051e1..9098f14081 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2204,6 +2204,7 @@ animation effects Off 關 no unused output name 沒有可用的輸出檔名 no output folder was chosen 未選擇輸出資料夾 @plural Downloaded {n} PDF|Downloaded {n} PDFs 已下載 {n} 個 PDF +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 個計算已改用新名稱。 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/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index a2b173c7a1..d257f9d2d2 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -147,6 +147,45 @@ fn format_validate_and_calculate_tabs() { assert_eq!(f.actions.calculate, Calculate::Notation("name".into())); } +/// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo +/// step, and says how many changed. +#[test] +fn renaming_a_field_says_how_many_calculations_now_use_the_new_name() { + use pdfcraft_engine::form_scripts::Calculate; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let rename = |h: &mut Harness<'static, PdfCraftApp>, from: &str, to: &str| { + h.state_mut().open_field_props(from, 0); + h.run_steps(2); + h.state_mut().field_props.as_mut().expect("open").name = to.into(); + h.get_by_label("OK").click(); + h.run_steps(3); + }; + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.state_mut().field_props.as_mut().expect("open").calculate = Calculate::Notation("name * 2".into()); + h.get_by_label("OK").click(); + h.run_steps(3); + let calculation = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().actions.calculate.clone() + }; + h.state_mut().toast = None; + rename(&mut h, "name", "full_name"); + assert_eq!(calculation(&h), Calculate::Notation("full_name * 2".into())); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("1 calculation now uses the new name.")); + h.state_mut().toast = None; + rename(&mut h, "city", "town"); + assert!(names(&h).contains(&"town".to_string())); + assert_eq!(h.state().toast, None, "no formula reads the calculated field itself"); + h.state_mut().execute("edit.undo"); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert!(names(&h).contains(&"name".to_string())); + assert_eq!(calculation(&h), Calculate::Notation("name * 2".into()), "undone with the rename"); +} + #[test] fn appearance_tab_restyles_the_field() { use pdfcraft_engine::{BorderStyle, FieldFont}; diff --git a/docs/roadmap.md b/docs/roadmap.md index c10d91d20f..d3cd66800e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,7 +143,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -202,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 42bbe8352f..2cfaa518e7 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4968,8 +4968,8 @@ milestone = "M6" status = "shipped" commands = ["form.field.properties"] tools = ["form_set_props", "form_fill"] -evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"] -notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending" +evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/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/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"] +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. Whether Acrobat does this is not observed." [[feature]] id = "form.props-selection-change" From 606c560d240f82ae246b2b03f1c159a01b8b77ea Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:46 -0400 Subject: [PATCH 14/20] Use chrono instead of hand-rolled UTC conversion (#3824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days/days_from_civil copies and the leap-year/month math around them (log timestamp, PDF /Info dates, ASN.1 UTCTime/GeneralizedTime, FormCalc Date/Num2Date, the Combine modified-time stamp, the form date picker, and the demo-PDF timestamps) with chrono as a workspace dependency. The engine's existing chrono (clock) is lifted to the workspace. Every output format is unchanged and pinned by tests. CLAUDE.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Guard chrono date paths: no panic on overflow, no '+' on 5-digit years Systemic chrono follow-ups to the UTC/calendar PR: - logging::timestamp and combine_ui::utc_stamp dropped chrono's From (which unwraps internally and panics on a clock past year 262143) for the fallible DateTime::from_timestamp, reading an out-of-range clock as the epoch, as the old clamping code did. - timestamp, utc_stamp, pdf_date and the xtask Stamp now write the year with {:04} instead of %Y, which prefixes a '+' before years past 9999; the old hand-rolled code wrote the plain zero-padded year. - forms_ui calendar navigation and length, and formcalc days_in, use checked_add for the December year successor so y + 1 can't overflow at i32::MAX / i64::MAX (reachable from a parsed or persisted date). Docstrings added to the chrono-touched civil_from_days and Time::from_unix for the docstring-coverage gate. Tests cover the out-of-range-epoch and 10000-01-01 cases for each. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Keep FormCalc dates exact past chrono's range MAX_DAYS accepts day numbers up to 1e8 (years past 270000), but NaiveDate stops near ±262143, so Num2Date(99000000) and Format fell back to 1900-01-01. days_from_civil, civil_from_days and days_in now have chrono convert within the date's 400-year Gregorian cycle and add whole cycles back (via i128), which gives the pre-chrono results for every year. Tests cover accepted inputs past chrono's range on both sides of the epoch, through Num2Date and Format, with values from the old formula. * Bump rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382/0383 rustls-webpki 0.103.15 -> 0.103.16 and webpki-root-certs 1.0.9 -> 1.0.10 (both semver-compatible, lockfile only) drop the distrusted emSign intermediates and root. Only these two entries change; cargo deny check advisories passes. Upstream main fails the same check. * logging/cos/sign/combine/demo: keep extended-year dates, no epoch fallback For consistency with the stricter no-epoch-fallback rule now agreed across the sibling chrono PRs (wordcraft#3427, filmcraft#2662): `DateTime::from_timestamp(..).unwrap_or_default()` changes a timestamp the old code converted past chrono's year 262143 range — it collapses to 1970-01-01 instead of the real proleptic-Gregorian date. The FormCalc Num2Date path was already fixed (FredApps approved); this applies the same fix to every remaining conversion. Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts the day within one cycle, whole cycles carry in the year (i128, saturated into i64). PDF/ASN.1 format-specific year handling is kept exactly as the old code had it. Verified the cycle result equals the old formula across a wide range and at the extremes. - apps/pdfcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (was 1970); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch). - crates/cos/src/writer.rs `pdf_date` (i64): cycle converter, no year clamp (old code wrote `y` as-is, so a negative year keeps its sign). Pins: pdf_date(10_000_000_000_000)="D:3188570520174640Z", pdf_date(-10_000_000_000_000)="D:-3149180813061320Z". - crates/sign/src/der.rs `Time::from_unix` (i64): cycle converter; the `year` field is a u32, so the year is cast `as u32` (wrapping) exactly as the old code did — ASN.1 year width preserved byte-for-byte. Pins: from_unix(10e12).year=318857, from_unix(-10e12).year=4_294_652_378, from_unix(i64::MAX).year=219_250_468, from_unix(i64::MIN).year=4_075_720_767. - crates/ui-egui/src/combine_ui.rs `utc_stamp` (u64): cycle converter; pre-1970 epoch clamp kept. Pins: utc_stamp(10e12)="318857-05-20 17:46 UTC", u64::MAX/2 s="292277026596-12-04 15:30 UTC". - xtask/src/demo_pdf/pdf.rs `Stamp::from_unix` (i64): cycle converter, no year clamp. Pins: from_unix(10e12).iso="318857-05-20T17:46:40Z"/.pdf="D:3188570520174640Z", from_unix(-10e12).iso="-314918-08-13T06:13:20Z". cos/sign/xtask built, tested (pdf_dates_are_utc_and_byte_identical, der::tests::times, stamps_round_trip), fmt and clippy clean. pdfcraft-ui-egui and the pdfcraft app can't link here (unrelated LLVM intrinsic bug in the rten-gemm dependency, 1.98.1), so their logging and combine_ui functions + tests were copied verbatim into a scratch crate with chrono 0.4.45 and pass there; fmt --check clean on all five packages. CI builds these on all platforms. * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- CLAUDE.md | 1 + Cargo.lock | 6 +++ Cargo.toml | 2 + apps/pdfcraft/Cargo.toml | 2 + apps/pdfcraft/src/logging.rs | 50 ++++++++++++------ crates/cos/Cargo.toml | 2 + crates/cos/src/writer.rs | 57 ++++++++++++++++----- crates/engine/Cargo.toml | 5 +- crates/js/Cargo.toml | 3 ++ crates/js/src/formcalc.rs | 88 +++++++++++++++++++++++--------- crates/js/src/tests.rs | 11 ++++ crates/sign/Cargo.toml | 2 + crates/sign/src/der.rs | 55 ++++++++++++++------ crates/ui-egui/Cargo.toml | 3 ++ crates/ui-egui/src/combine_ui.rs | 62 +++++++++++++++++----- crates/ui-egui/src/forms_ui.rs | 24 +++++---- xtask/Cargo.toml | 2 + xtask/src/demo_pdf/pdf.rs | 43 +++++++++++----- 18 files changed, 314 insertions(+), 104 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 12779b1e35..34f8ab85cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. - Tests mirror the zooms: property → unit → crate integration → end-to-end. - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. diff --git a/Cargo.lock b/Cargo.lock index 449b1f82b5..a439c0b805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3940,6 +3940,7 @@ dependencies = [ name = "pdfcraft" version = "0.11.0" dependencies = [ + "chrono", "craft-quit", "eframe", "egui", @@ -4048,6 +4049,7 @@ dependencies = [ name = "pdfcraft-cos" version = "0.11.0" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", @@ -4190,6 +4192,7 @@ name = "pdfcraft-js" version = "0.11.0" dependencies = [ "boa_engine", + "chrono", ] [[package]] @@ -4327,6 +4330,7 @@ dependencies = [ "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -4362,6 +4366,7 @@ dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "chrono", "craft-quit", "craft-ui", "eframe", @@ -8035,6 +8040,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", "pdfcraft-cos", "pdfcraft-oracle", diff --git a/Cargo.toml b/Cargo.toml index 18afce999f..0106433dfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" kurbo = "0.13" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } # testing proptest = "1" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 3ce1c96ef3..6943ad6e44 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -23,6 +23,8 @@ egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } +# The log timestamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index 71ed1d2d2f..bef27de169 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most -/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 +/// is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -337,6 +345,18 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/cos/Cargo.toml b/crates/cos/Cargo.toml index 9ef9126ca8..a2fb3f4c4e 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-crypt = { workspace = true } pdfcraft-filters = { workspace = true } thiserror = { workspace = true } +# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 331f97d443..786dcd9683 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -578,22 +578,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { - let days = unix_secs.div_euclid(86_400); - let secs = unix_secs.rem_euclid(86_400); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; + let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a + // bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is. format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, +/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every +/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back +/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar +/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -656,6 +667,26 @@ mod tests { } } + #[test] + fn pdf_dates_are_utc_and_byte_identical() { + // `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote. + assert_eq!(pdf_date(0), "D:19700101000000Z"); + assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z"); + // Leap days, including the 400-year rule, and the last second of a year. + assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z"); + assert_eq!(pdf_date(951_782_400), "D:20000229000000Z"); + assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z"); + // Before the epoch (seconds into a day are counted forward from midnight UTC). + assert_eq!(pdf_date(-1), "D:19691231235959Z"); + // Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did. + assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps + // its sign, exactly as before). + assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); + assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + } + #[test] fn reals_keep_full_precision_without_exponents() { for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ff5e7611d8..bd92d3dca4 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -48,5 +48,6 @@ icu_properties = "2.3" unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# Already in the graph through lopdf; used only for the local UTC offset ("today"). -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace +# entry is std-only; this target adds `clock` for `chrono::Local::now`. +chrono = { workspace = true, features = ["clock"] } diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index ee09b8a8e9..c0df850575 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,9 @@ workspace = true [dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } +# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code); +# std-only, so it stays wasm-safe. +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index dc32d457bf..1ab7ca24e5 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1838,30 +1838,50 @@ impl Interp<'_> { // ── built-in functions ────────────────────────────────────────────────────────────────────── +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + /// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = (i64::from(m) + 9) % 12; - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let unix = era * 146_097 + doe - 719_468; - unix + 25_568 + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) } +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. fn civil_from_days(n: i64) -> (i64, u32, u32) { - let z = n - 25_568 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } } /// Day numbers (and years) beyond this are refused rather than computed. @@ -1925,10 +1945,15 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { /// Number of days in month `m` (1-12) of year `y`, leap years included. fn days_in(y: i64, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, _ => 31, } } @@ -2591,6 +2616,23 @@ mod tests { assert_eq!(days_from_civil(1900, 1, 1), 1); assert_eq!(days_from_civil(1970, 1, 1), 25_568); assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + // Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono + // formula, both directions, both sides of the epoch. + for (n, date) in [ + (99_000_000, (272_952, 10, 16)), + (99_999_999, (275_690, 9, 11)), + (-99_000_000, (-269_153, 3, 16)), + (-99_999_999, (-271_891, 4, 20)), + (72_354_905, (200_000, 12, 31)), + (-73_742_460, (-200_000, 1, 1)), + ] { + assert_eq!(civil_from_days(n), date, "{n}"); + assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}"); + } + assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in(-269_153, 2), 28); + assert_eq!(days_in(i64::MAX, 12), 31); + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3))); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 61d3a71e51..f6bbdb3523 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -651,6 +651,17 @@ mod formcalc { assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); } + /// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the + /// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format. + #[test] + fn date_numbers_beyond_chronos_range_keep_their_dates() { + assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16"); + assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16"); + assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11"); + assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16"); + assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20"); + } + #[test] fn str_and_format_round_exact_ties_away_from_zero() { assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 73210cff4e..d58811d552 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-cos = { workspace = true } pdfcraft-annot = { workspace = true } pdfcraft-fonts = { workspace = true } thiserror.workspace = true +# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } base64 = "0.22" sha1 = "0.11" sha2 = "0.11" diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index 5c816eae9e..6248c1e9e1 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -285,6 +285,9 @@ fn contents_len(input: &[u8], form: Form, depth: usize) -> Result i64 { - let (y, m) = if self.month <= 2 { (self.year as i64 - 1, self.month as i64 + 9) } else { (self.year as i64, self.month as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + self.day as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + use chrono::Datelike; + // The calendar date goes through chrono; the time of day is a plain second count, as + // before. A date chrono cannot represent (an out-of-range field) reads as the epoch day, + // which no well-formed UTCTime/GeneralizedTime produces. + let days = i32::try_from(self.year) + .ok() + .and_then(|y| chrono::NaiveDate::from_ymd_opt(y, self.month, self.day)) + .map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE); days * 86_400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 } + /// The UTC time at `t` seconds since 1970-01-01. + /// + /// The calendar date goes through chrono's 400-year Gregorian cycle (146_097 days) so a `t` + /// past `NaiveDate`'s ±262143-year range keeps the proleptic Gregorian date the old hand-rolled + /// converter produced instead of falling back to the epoch. The `year` field is a `u32`, so the + /// year is cast exactly as the old code did (`as u32`, wrapping) — no well-formed signing time + /// is anywhere near that range, and this preserves byte-for-byte the old output everywhere. pub fn from_unix(t: i64) -> Time { - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = (doy - (153 * mp + 2) / 5 + 1) as u32; - let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let year = (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as u32; + use chrono::Datelike; + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + let (year, month, day) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + // Match the old `as u32` cast: saturate i128→i64 first (the old year was i64), then wrap into u32. + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }) as u32, d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + }; Time { year, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 } } @@ -591,5 +604,13 @@ mod tests { assert_eq!(Tlv::parse_all(&far.encode()).unwrap().tag, tag::GENERALIZED_TIME); assert_eq!(Time::from_pdf("D:20261002113005+02'00'"), Some(Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 })); assert_eq!(Time::from_unix(0), Time { year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 }); + // Past chrono's ±262143-year range `from_unix` keeps the proleptic Gregorian date the old + // hand-rolled converter gave, not the epoch. No real signing time is near this; the `year` + // field is a `u32`, so the year wraps exactly as the old `as u32` cast did (values pinned + // from the old formula). This also covers both i64 extremes so neither div_euclid panics. + assert_eq!(Time::from_unix(10_000_000_000_000), Time { year: 318857, month: 5, day: 20, hour: 17, minute: 46, second: 40 }); + assert_eq!(Time::from_unix(-10_000_000_000_000), Time { year: 4_294_652_378, month: 8, day: 13, hour: 6, minute: 13, second: 20 }); + assert_eq!(Time::from_unix(i64::MAX), Time { year: 219_250_468, month: 12, day: 4, hour: 15, minute: 30, second: 7 }); + assert_eq!(Time::from_unix(i64::MIN), Time { year: 4_075_720_767, month: 1, day: 27, hour: 8, minute: 29, second: 52 }); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 42e9474f07..8b091945ab 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,9 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono +# (no hand-rolled calendar code); std-only, so it stays wasm-safe. +chrono = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 6704ce2276..62d9163154 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1861,24 +1861,37 @@ pub(crate) fn ago(time: SystemTime) -> String { } } -/// `time` as "2026-10-08 14:05 UTC". +/// `time` as "2026-10-08 14:05 UTC". A clock before 1970 reads as the epoch; past chrono's year +/// 262143 range the proleptic Gregorian date is kept via the 400-year cycle, not the epoch. fn utc_stamp(time: SystemTime) -> String { let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let days = i64::try_from(secs / 86_400).unwrap_or(0); - let rem = secs % 86_400; - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count +/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still +/// produced a date. The Gregorian calendar repeats every 400 years (146_097 days), so chrono +/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then +/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old +/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. pub fn open_combine_tab(&mut self) { @@ -2596,6 +2609,29 @@ impl PdfCraftApp { mod tests { use super::*; + #[test] + fn utc_stamp_is_utc_minutes_and_clamps_pre_epoch() { + use std::time::Duration; + let at = |s: u64| utc_stamp(SystemTime::UNIX_EPOCH + Duration::from_secs(s)); + assert_eq!(at(0), "1970-01-01 00:00 UTC"); + assert_eq!(at(1_791_446_357), "2026-10-08 07:59 UTC"); + // The 400-year leap rule. + assert_eq!(at(951_782_400), "2000-02-29 00:00 UTC"); + // A clock before 1970 reads as the epoch, as the old `unwrap_or(0)` did. + assert_eq!(utc_stamp(SystemTime::UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01 00:00 UTC"); + // Past chrono's range keeps the proleptic Gregorian date the old code gave, not the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(utc_stamp(far), "318857-05-20 17:46 UTC"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(utc_stamp(far), "292277026596-12-04 15:30 UTC"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(at(253_402_300_800), "10000-01-01 00:00 UTC"); + } + /// A one-page PDF that opens with "pw". fn locked() -> Vec { let objs = [ diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 612b985e65..27c97a240e 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -290,19 +290,25 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st .strong(), ); if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() { - (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + // `y + 1` would overflow at `i32::MAX` (a date parsed or persisted with + // that year); stay put on December of the last year instead of panicking. + (y, m) = if m == 12 { y.checked_add(1).map_or((y, m), |next| (next, 1)) } else { (y, m + 1) }; } }); // Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday). - let days_from_civil = |y: i32, m: u32, d: u32| -> i64 { - let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * m + 2) / 5 + d as i64 - 1; - era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468 + use chrono::Datelike; + // The month the calendar shows: the weekday of its 1st (0 = Sunday) and its + // length, both from chrono. A year chrono can't represent falls back to a plain + // 31-day month starting on Sunday, which only the arrows can reach. + let first_of = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + let first = first_of(y, m).map_or(0, |d| d.weekday().num_days_from_sunday() as usize); + // `y + 1` for December would overflow at `i32::MAX`; a year chrono can't represent + // (or that overflows here) falls back to the plain 31-day month. + let next_y = if m == 12 { y.checked_add(1) } else { Some(y) }; + let len = match (first_of(y, m), next_y.and_then(|next| first_of(next, if m == 12 { 1 } else { m + 1 }))) { + (Some(a), Some(b)) => (b - a).num_days() as usize, + _ => 31, }; - let first = (days_from_civil(y, m, 1) + 4).rem_euclid(7) as usize; - let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7c6ca7aedb..7a33bad875 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -15,3 +15,5 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } sha2 = "0.11" toml = "1" +# The demo-PDF time stamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } diff --git a/xtask/src/demo_pdf/pdf.rs b/xtask/src/demo_pdf/pdf.rs index 297d4ce93a..b51ea1a643 100644 --- a/xtask/src/demo_pdf/pdf.rs +++ b/xtask/src/demo_pdf/pdf.rs @@ -75,22 +75,33 @@ impl Stamp { } pub fn from_unix(secs: i64) -> Self { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); + let (y, m, d) = Self::civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") } } + /// Days since 1970-01-01 → proleptic Gregorian (year, month, day). + /// + /// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date, + /// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it. + /// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day + /// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't + /// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163. + fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } + } + /// The same moment shifted by `minutes` (used to order threaded replies). pub fn plus_minutes(&self, minutes: i64) -> Self { Self::from_unix(self.unix + minutes * 60) @@ -429,6 +440,14 @@ mod tests { assert_eq!(s.iso, "2026-09-21T14:13:20Z"); assert_eq!(s.plus_minutes(0).pdf, s.pdf); assert_eq!(s.plus_minutes(60 * 24).iso, "2026-09-22T14:13:20Z"); + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(Stamp::from_unix(253_402_300_800).iso, "10000-01-01T00:00:00Z"); + assert_eq!(Stamp::from_unix(253_402_300_800).pdf, "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (no year clamp — a negative year keeps its sign). + assert_eq!(Stamp::from_unix(10_000_000_000_000).iso, "318857-05-20T17:46:40Z"); + assert_eq!(Stamp::from_unix(10_000_000_000_000).pdf, "D:3188570520174640Z"); + assert_eq!(Stamp::from_unix(-10_000_000_000_000).iso, "-314918-08-13T06:13:20Z"); } #[test] From e451934f7217f09d4ea6942a1d9d36f80d5a7ac2 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 01:56:27 +0200 Subject: [PATCH 15/20] M6: calculation order: read, check, fix and set the order fields calculate in (#3708) * M6: renaming a field renames it in the calculations that read it A rename silently broke every formula that used the field: a sum dropped it, notation read it as 0 and a script kept a stale value. set_props now rewrites the name in each field's Calculate script, in the same edit: AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation) and getField calls naming it in one string literal. The rest of each script is kept as written; names built at run time, non-UTF-8 script streams and a name another field still has are left alone, and a self-check keeps a script whose meaning would change. Notation is read by one lexer shared by notation_js, eval_notation and the rename. form_set_props returns formulas_updated; Field Properties says how many calculations now use the new name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form_set_props leaves the renamed field out of formulas_updated by object The renamed field was matched by the name given, but set_props stores it trimmed and under its parent's prefix. When the same call changed the field's own formula, formulas_updated listed the field as one the rename had rewritten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: calculation order: read, check, fix and set the order fields calculate in A form's calculations run in its calculation order (/CO), which a field joins when it gets a calculation, so a chain defined in the wrong order shows stale values. calculation_order reads the order against the fields, with what each formula reads (field lists, notation names, literal getField names) and what is wrong: a field reading one that calculates later, a loop (strongly connected components, found without recursion), a field /CO leaves out, and /CO entries that calculate nothing. Fix order is a stable topological sort that moves only what must move and leaves loops alone. set_calculation_order writes /CO afresh; a name two fields share (a malformed form) stands for the next of them in the current order. Edit::SetCalculationOrder sets it in one undo step and recalculates, and form_calculation_order reads, sets, moves one field or fixes the order headlessly. An edited formula now keeps its field's place in /CO, a field /CO lists twice calculates once, and recalculation runs each field by object, so two fields sharing a name both calculate. The /CO code of the Calculate tab, field scripts and recalculation goes through one place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +-- ROADMAP.md | 1 + crates/automation/src/forms.rs | 50 ++- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 5 + crates/automation/tests/automation.rs | 58 ++++ crates/engine/src/lib.rs | 32 +- crates/engine/src/tests.rs | 41 +++ crates/forms/README.md | 8 + crates/forms/src/af.rs | 53 ++++ crates/forms/src/author.rs | 21 +- crates/forms/src/calc_order.rs | 429 ++++++++++++++++++++++++++ crates/forms/src/lib.rs | 25 +- crates/forms/src/scripting.rs | 20 +- crates/forms/src/tests.rs | 153 +++++++++ crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.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/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.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/lt.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sl.tsv | 1 + crates/ui-egui/src/i18n/te.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 + docs/roadmap.md | 3 +- parity/acrobat-features.toml | 6 +- 40 files changed, 885 insertions(+), 88 deletions(-) create mode 100644 crates/forms/src/calc_order.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 361a453ecc..d512414448 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8" +sha256 = "18d2b88bb336ec840dc460ad28ccea620a96eeea26e427cd302eb03e964e6ed2" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f" +sha256 = "14be17199efc810682bd021ec807c6488e96dd5e1c97a009f4c31fae125da0bc" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe" +sha256 = "483c060d64d68431afd0a0eae3ef7b356cf29fcbd6a94c7242ef225c814a0077" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5" +sha256 = "7e8158add249b105d85a398dd8b5ee573f9af6631ade6e4457799d9371be747e" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e" +sha256 = "4b3520cb42f50e3ac221c1ccf40e86f955e900f16f5b3251b54f80b9d068df81" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0" +sha256 = "b6f566905f14d7669d8c220c5cd84886abe4b6edfa8cbdd213350f4ad1923e7a" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4" +sha256 = "383a20170ea9483c032fc40979b5b5bbd0d38b1af29b4d9153c68f2280ecb576" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54" +sha256 = "6f0852d52d2498b1b7713dbb67b34584f365c1ec9504964f8da28c40e1875d70" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e" +sha256 = "a52bad8366e4911d49deb1ba0f29f8844ae24eac372a1f0040eb1a4909dc32f3" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65" +sha256 = "c401a6505dadfa078cf45ee1c1fa834fe4aa21eb7656b641a10dd7da2613fd35" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88" +sha256 = "a1e65941b372579a4f1bb63cac4d350620715357fd24e457a8e649ff611b5e25" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f" +sha256 = "518c3db5cfef1bd1f3cd6249b2faf783b2301d2a77931315a77e28bfab8250e3" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31" +sha256 = "c22f0dab633f832d0ed9d7ad9c97f5d1b636505144593917fc18bc4dff40458c" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3" +sha256 = "90226cb9e54441a926da35eeaa3f8272dcf0b7da4e269ba5ccb37c0404291778" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba" +sha256 = "e0874fddf6833e6005604dbc16f4b9dbb72d07cff3ce18bb8cbece0fee2fc5e0" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98" +sha256 = "1ae379d3bb19741c50e05a967f3b33f73d34df0b23a55257a04524c917c0a53d" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97" +sha256 = "6efd057033876643b4a4a5a9179ad9c4291f6201c04c2cc7948f4d454e6f90e1" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c" +sha256 = "b929e99180a0fa23cdfb8c8fa77f11e458b9ef9adc269890792ac066f0f0713c" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0" +sha256 = "f46a9aa4c7b34ef97d5f0fa7dc93ce36cdece9498a8bf51cd83fecf002b98b6e" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc" +sha256 = "49275046f26850714e87a4c862cd0cac8eee175a39ec839f6b348e1e3ccc6bef" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2" +sha256 = "31fbec695e02ea651daae01f2673a5dadfc1430080f2adb2c6d441bc06900cf4" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8" +sha256 = "cd4d613d1e8d9b5cc1bc82c5a9c35968048319027b0470d1c644c6a3bc617243" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index 4d84708532..7cb7c17dc3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index bd70b65431..baeb4dd521 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -2,7 +2,7 @@ //! prepare a form (add, change and delete fields). use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate}; -use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; +use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; @@ -536,6 +536,54 @@ impl Automation { Ok(out) } + pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result { + let current = self.doc(a)?.calculation_order(); + let fix = a.opt_bool("fix")?.unwrap_or(false); + let field = a.opt_str("field")?; + let order = a.get("order").map(|_| a.strs("order")).transpose()?; + if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 { + return Err(bad("give one change at a time: order, field with move, or fix")); + } + let names: Option> = if let Some(order) = order { + Some(order.into_iter().map(str::to_owned).collect()) + } else if let Some(field) = field { + let earlier = match a.opt_str("move")?.unwrap_or("earlier") { + "earlier" => true, + "later" => false, + m => return Err(bad(format!("move must be earlier or later, not {m:?}"))), + }; + let mut names: Vec = current.fields.iter().map(|f| f.name.clone()).collect(); + let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?; + // Already first or last: nothing moves. + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + j.map(|j| { + names.swap(i, j); + names + }) + } else { + (fix && current.fixable()).then(|| current.fixed()) + }; + let mut out = match names { + Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?, + None => json!({}), + }; + let order = self.doc(a)?.calculation_order(); + let issue = |i: &CalculationIssue| match i { + 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" }), + }; + 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::>() })) + .collect(); + out["extra"] = json!(order.extra); + out["fixable"] = json!(order.fixable()); + Ok(out) + } + fn tab_sequence(&self, a: &Args) -> Result { let doc = self.doc(a)?; let mut seq: Vec<(usize, &str)> = diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index af0583717f..31bb8da6ea 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -729,6 +729,7 @@ impl Automation { "form_set_props" => self.form_set_props(&a)?, "form_delete_field" => self.form_delete_field(&a)?, "form_tab_order" => self.form_tab_order(&a)?, + "form_calculation_order" => self.form_calculation_order(&a)?, "doc_export_data" => self.doc_export_data(&a)?, "doc_import_data" => self.doc_import_data(&a)?, "redact_mark" => self.redact_mark(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 597882d2f5..cd8694e1d2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -410,6 +410,11 @@ 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.") + .with(schema( + json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }), + &["doc"], + )), t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.") .destructive() .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 91a2656f48..5bd3a758f9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3853,6 +3853,64 @@ fn renaming_a_field_keeps_the_formulas_that_read_it() { assert_eq!(r["formulas_updated"], json!([])); } +/// The calculation order through the tool: read with what each formula reads and what is wrong, +/// fixed so stale values come out right, moved, set, and undone. +#[test] +fn the_calculation_order_is_read_fixed_and_set() { + let dir = workdir("calcorder"); + 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", "total", "tax", "x", "y"].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 })); + } + // Defined backwards: total and tax calculate before the fields they read. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "tax", "event": "calculate", "script": "event.value = getField(\"sub\").value / 10;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * price" } })); + // x and y read each other. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "x", "calculate": { "notation": "y + 1" } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "y", "calculate": { "notation": "x + 1" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "3", "price": "10" } })); + let value = |a: &mut Automation, name: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()).unwrap() + }; + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "stale: they calculate too early"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!(r["fields"][0]["reads"], json!(["sub", "tax"])); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "runs_too_early", "field": "tax" }, { "issue": "runs_too_early", "field": "sub" }])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "runs_too_early", "field": "sub" }]), "a script's reads are checked too"); + assert_eq!(r["fields"][3]["issues"], json!([{ "issue": "cycle", "fields": ["y"] }])); + assert_eq!((r["fixable"].clone(), r["extra"].clone()), (json!(true), json!([]))); + assert!(r.get("undo").is_none(), "reading changes nothing"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "fix": true })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "x", "y"]), "only what must move moves; the loop stays"); + assert_eq!(r["undo"], "Set calculation order"); + assert_eq!(r["fixable"], false); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("33"), json!("3")), "recalculated in the new order"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "field": "y", "move": "earlier" })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "y", "x"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "order": ["x"] })); + assert_eq!(r["calculation_order"], json!(["x", "sub", "tax", "total", "y"]), "the fields named first, the others after them"); + for bad in [json!({ "order": ["qty"] }), json!({ "order": ["sub", "sub"] }), json!({ "field": "qty" }), json!({ "fix": true, "field": "x" })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(a.call("form_calculation_order", &args).is_err(), "{bad}"); + } + for _ in 0..3 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdc7eb646f..2125a5b9d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,10 +56,10 @@ pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; pub use pdfcraft_forms::{ - BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, - Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed, - flags as field_flags, + BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, + FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, + Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, + calculations_changed as form_calculations_changed, flags as field_flags, }; pub use pdfcraft_a11y as a11y; @@ -240,7 +240,7 @@ enum Scope { /// Edits during which field scripts run. fn uses_scripts(edit: &Edit) -> bool { match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true, + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), _ => false, } @@ -585,6 +585,12 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default() } + /// The form's calculation order: its calculated fields in the order they calculate, what each + /// formula reads, what is wrong with the order, and the order Fix order writes. + pub fn calculation_order(&self) -> CalculationOrder { + self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default() + } + /// Add alternate text: the figures, in document order. pub fn figures(&self) -> Vec { self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default() @@ -1617,6 +1623,11 @@ pub enum Edit { name: String, earlier: bool, }, + /// Set the calculation order: these calculated fields first, in this order, then the others + /// in their current order (see [`Document::calculation_order`]). The form recalculates. + SetCalculationOrder { + names: Vec, + }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, @@ -1886,6 +1897,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete field", "Duplicate field", "Set tab order", + "Set calculation order", "Change initial view", "Set alternate text", "Mark figure as decorative", @@ -2012,6 +2024,7 @@ impl Edit { Edit::DeleteField { .. } => Label::of("Delete field"), Edit::DuplicateField { .. } => Label::of("Duplicate field"), Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), + Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), Edit::SetInitialView(_) => Label::of("Change initial view"), Edit::AutoTagText => Label::of("Automatically tag text"), Edit::SetAltText { .. } => Label::of("Set alternate text"), @@ -2209,6 +2222,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::DuplicateField { .. } | Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } | Edit::SetInitialView(_) | Edit::AutoTagText | Edit::SetAltText { .. } @@ -2497,6 +2511,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?, Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?, Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?, + // As after a script edit: the values the new order gives show at once. + Edit::SetCalculationOrder { names } => { + pdfcraft_forms::set_calculation_order(doc, names)?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?, Edit::AutoTagText => { a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41dac..080dc32dd3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1940,6 +1940,47 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +/// Setting the calculation order is one undoable edit that recalculates: values that ran too +/// early come out right at once, a script calculation included, and undo brings back the old +/// order and values together. +#[test] +fn setting_the_calculation_order_recalculates_and_undoes() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = session_with(1); + for (i, name) in ["qty", "sub", "total", "tax"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let calculate = |name: &str, c: Calculate| Edit::SetFieldProps { + name: name.into(), + props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }), + }; + // Defined backwards: total, which reads sub, calculates first. + s.apply(id, calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })).unwrap(); + s.apply(id, calculate("sub", Calculate::Notation("qty * 2".into()))).unwrap(); + let script = Some("event.value = this.getField(\"total\").value / 10;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "tax".into(), event: "calculate".into(), script }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("5".into()) }).unwrap(); + let values = |s: &Session| { + let form = &s.get(id).unwrap().form; + ["sub", "total", "tax"].map(|n| form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default()) + }; + assert_eq!(values(&s), ["10", "0", "0"], "total and tax ran before sub"); + let order = s.get(id).unwrap().calculation_order(); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["total", "sub", "tax"]); + assert_eq!(order.fields[0].issues, [CalculationIssue::RunsTooEarly { field: "sub".into() }]); + assert_eq!(order.fixed(), ["sub", "total", "tax"]); + s.apply(id, Edit::SetCalculationOrder { names: order.fixed() }).unwrap(); + assert_eq!(values(&s), ["10", "10", "1"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set calculation order")); + assert!(s.get(id).unwrap().calculation_order().fields.iter().all(|f| f.issues.is_empty())); + s.undo(id).unwrap(); + assert_eq!(values(&s), ["10", "0", "0"]); + assert_eq!(s.get(id).unwrap().calculation_order().fixed(), ["sub", "total", "tax"], "the old order is back"); + assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { diff --git a/crates/forms/README.md b/crates/forms/README.md index 2ba38097ed..b567009f8c 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -63,6 +63,14 @@ delete_field(&mut doc, "email")?; script stream that isn't UTF-8, other events' scripts and a name another field still has are 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`, + `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 + 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. - `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values). `FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears that label, independently of the field name. The index is 0-based. A label-only edit redraws diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index ab7fc327c9..6147b3d482 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -698,6 +698,34 @@ pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { (got == expected).then_some(out) } +/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the +/// order it first names them. +pub fn calculation_reads(js: &str) -> Vec { + let sites = calculation_sites(js); + unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) +} + +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. + pub fn reads(&self) -> Vec { + match self { + Calculate::None => Vec::new(), + Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { + Notation::Name(name) => Some(name), + _ => None, + })), + } + } +} + +/// `names` without repeats, in order. +fn unique(names: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + names.into_iter().filter(|n| seen.insert(n.clone())).collect() +} + // ── running them ──────────────────────────────────────────────────────────────────────────── /// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a @@ -1528,6 +1556,31 @@ mod tests { let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old)); proptest::prop_assert_eq!(back, Some(js)); } + + /// 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] + fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let c = if sum { + Calculate::Simple { op: CalcOp::Product, fields: names.clone() } + } else { + Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" + ")) + }; + let js = calculate_js(&c).unwrap(); + proptest::prop_assert_eq!(calculation_reads(&js), c.reads()); + proptest::prop_assert_eq!(c.reads(), unique(names)); + } + } + + #[test] + fn a_calculation_reads_each_named_field_once() { + assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]); + assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]); + assert_eq!(Calculate::None.reads(), Vec::::new()); + let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;"; + assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known"); + assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); } #[test] diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index b4fddb589d..53c90b0fcc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -1140,26 +1140,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if *c != crate::af::Calculate::None { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; + crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?; } doc.update_dict(f.obj, |d| { if aa.is_empty() { diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs new file mode 100644 index 0000000000..e75a05d475 --- /dev/null +++ b/crates/forms/src/calc_order.rs @@ -0,0 +1,429 @@ +//! The calculation order (`/CO`, ISO 32000-2 §12.7.3): the calculated fields in the order they +//! recalculate, the fields each one's formula reads, and what is wrong with that order. + +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque}; + +use pdfcraft_cos::{Document, ObjRef, Object}; + +use crate::{Field, FormError, acroform, fields}; + +/// At most this many reads between calculated fields are checked. A group name stands for every +/// field under it, so a form could otherwise make a check build billions of them. +const MAX_LINKS: usize = 1 << 20; + +/// A calculated field, at its place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub struct CalculatedField { + /// The field's full name. + pub name: String, + /// 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, + /// `/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. + pub issues: Vec, +} + +/// Something wrong with a calculated field's place in the calculation order. +#[derive(Clone, Debug, PartialEq)] +pub enum CalculationIssue { + /// 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 + /// reads itself directly). No order is right for a loop: one of the formulas has to change. + Cycle { fields: Vec }, + /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers + /// may not calculate it at all. + NotInOrder, +} + +/// A form's calculation order and what is wrong with it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CalculationOrder { + /// The calculated fields in the order [`crate::recalculate`] runs them: as `/CO` lists them, + /// then the ones it leaves out, in field order. + pub fields: Vec, + /// `/CO` entries that calculate nothing: a field without a calculation, a second entry for a + /// field, or an object that isn't a field (written as `12 0 R`). + pub extra: Vec, + /// Fix order's order, as indexes into `fields`. + fixed: Vec, +} + +impl CalculationOrder { + /// The names in the order Fix order writes: each field after the calculated fields it reads, + /// keeping the current order wherever that allows. Reads within a loop are left as they are. + pub fn fixed(&self) -> Vec { + self.fixed.iter().filter_map(|&i| self.fields.get(i)).map(|f| f.name.clone()).collect() + } + + /// Whether Fix order changes anything: a field moves, joins `/CO`, or an extra entry goes. + pub fn fixable(&self) -> bool { + !self.extra.is_empty() || self.fields.iter().any(|f| !f.listed) || self.fixed.iter().enumerate().any(|(k, &i)| k != i) + } +} + +/// Whether a field has a calculation: a Calculate tab formula or a custom Calculate script. +pub(crate) fn is_calculated(f: &Field) -> bool { + f.actions.calculate != crate::af::Calculate::None || f.actions.scripts.calculate.is_some() +} + +/// The entries of `/CO`, as written. +pub(crate) fn co_entries(doc: &Document) -> Vec { + acroform(doc).and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())).unwrap_or_default() +} + +/// Keep `/CO` in step with whether field `obj` calculates: a field that gets a calculation joins +/// the end of the order, one that loses it leaves, and one whose calculation changes keeps its +/// place, so editing a formula doesn't undo an order set by hand. +pub(crate) fn keep_listed(doc: &mut Document, obj: ObjRef, calculates: bool) -> Result<(), FormError> { + let mut co = co_entries(doc); + match (co.iter().any(|o| o.as_ref() == Some(obj)), calculates) { + (false, true) => co.push(Object::Ref(obj)), + (true, false) => co.retain(|o| o.as_ref() != Some(obj)), + _ => return Ok(()), + } + write_co(doc, co) +} + +/// Write `/CO` (none when `co` is empty), creating the form if needed. +fn write_co(doc: &mut Document, co: Vec) -> Result<(), FormError> { + let af_ref = crate::author::ensure_form(doc)?; + doc.update_dict(af_ref, |d| { + if co.is_empty() { + d.remove(b"CO"); + } else { + d.set(b"CO".to_vec(), Object::Array(co)); + } + })?; + Ok(()) +} + +/// The calculated fields in the order they recalculate, as indexes into `fields` paired with +/// whether `/CO` lists them, and the `/CO` entries that calculate nothing. +pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>, Vec) { + let mut by_obj: HashMap = HashMap::with_capacity(fields.len()); + for (i, f) in fields.iter().enumerate() { + by_obj.entry(f.obj).or_insert(i); + } + let mut placed = HashSet::new(); + let (mut run, mut extra) = (Vec::new(), Vec::new()); + for entry in co_entries(doc) { + let field = entry.as_ref().and_then(|r| by_obj.get(&r).copied()).and_then(|i| Some((i, fields.get(i)?))); + match field { + Some((i, f)) if is_calculated(f) && placed.insert(i) => run.push((i, true)), + Some((_, f)) => extra.push(f.name.clone()), + None => extra.push(entry.as_ref().map_or_else(|| "not a reference".to_owned(), |r| r.to_string())), + } + } + run.extend(fields.iter().enumerate().filter(|(i, f)| is_calculated(f) && !placed.contains(i)).map(|(i, _)| (i, false))); + (run, extra) +} + +/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's +/// reads and issues, and Fix order's order. +pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { + let (run, extra) = run_order(doc, fields); + let calculated: Vec<(&Field, bool)> = run.iter().filter_map(|&(i, listed)| Some((fields.get(i)?, listed))).collect(); + let reads: Vec> = calculated + .iter() + .map(|(f, _)| { + let mut names = f.actions.calculate.reads(); + if let Some(js) = &f.actions.scripts.calculate { + for name in crate::af::calculation_reads(js) { + if !names.contains(&name) { + names.push(name); + } + } + } + names + }) + .collect(); + let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); + let component = components(&links); + 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 read = links.get(k).map(Vec::as_slice).unwrap_or_default(); + for &j in read { + if j > k && component.get(j).copied() != own { + issues.push(CalculationIssue::RunsTooEarly { field: name(j) }); + } + } + // Only the loop's fields it reads itself are named, so a loop through thousands of fields + // isn't listed again for each of them. + let looped: Vec = read.iter().filter(|&&j| component.get(j).copied() == own).map(|&j| name(j)).collect(); + if !looped.is_empty() { + issues.push(CalculationIssue::Cycle { fields: looped }); + } + if !listed { + issues.push(CalculationIssue::NotInOrder); + } + out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); + } + let fixed = fixed_order(&links, &component); + CalculationOrder { fields: out, extra, fixed } +} + +/// 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. +fn links<'a>(names: impl Iterator, reads: &[Vec]) -> Vec> { + let mut sorted: Vec<(&str, usize)> = names.enumerate().map(|(k, n)| (n, k)).collect(); + sorted.sort_unstable(); + let mut budget = MAX_LINKS; + reads + .iter() + .map(|names| { + let mut out = Vec::new(); + for name in names { + let prefix = format!("{name}."); + let exact = sorted.partition_point(|(n, _)| *n < name.as_str()); + let under = sorted.partition_point(|(n, _)| *n < prefix.as_str()); + let same = sorted.get(exact..).unwrap_or_default().iter().take_while(|(n, _)| n == name); + let below = sorted.get(under..).unwrap_or_default().iter().take_while(|(n, _)| n.starts_with(&prefix)); + for &(_, k) in same.chain(below) { + if budget == 0 { + break; + } + budget -= 1; + out.push(k); + } + } + out.sort_unstable(); + out.dedup(); + out + }) + .collect() +} + +/// The strongly connected components of `links` (Tarjan's algorithm, without recursion): one +/// component number per node. Fields in one component read each other in a loop. +fn components(links: &[Vec]) -> Vec { + const UNSEEN: usize = usize::MAX; + let n = links.len(); + let (mut index, mut low, mut component) = (vec![UNSEEN; n], vec![0; n], vec![UNSEEN; n]); + let mut on_stack = vec![false; n]; + let (mut stack, mut work): (Vec, Vec<(usize, usize)>) = (Vec::new(), Vec::new()); + let (mut next, mut count) = (0, 0); + // Every index below is a node: `links` only holds places below `n` (see `links`). + for root in 0..n { + if index[root] != UNSEEN { + continue; + } + work.push((root, 0)); + while let Some((v, e)) = work.pop() { + if e == 0 { + index[v] = next; + low[v] = next; + next += 1; + stack.push(v); + on_stack[v] = true; + } + if let Some(&w) = links[v].get(e) { + work.push((v, e + 1)); + if index[w] == UNSEEN { + work.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + if low[v] == index[v] { + while let Some(w) = stack.pop() { + on_stack[w] = false; + component[w] = count; + if w == v { + break; + } + } + count += 1; + } + if let Some(&(parent, _)) = work.last() { + low[parent] = low[parent].min(low[v]); + } + } + } + component +} + +/// Fix order: every node after the nodes it reads in other components, the earliest ready node +/// first, so an order that is already right stays as it is and otherwise only what must move +/// moves. Reads within a component (a loop) are left out: no order satisfies them. +fn fixed_order(links: &[Vec], component: &[usize]) -> Vec { + let n = links.len(); + let mut waits = vec![0usize; n]; + let mut readers: Vec> = vec![Vec::new(); n]; + for (k, read) in links.iter().enumerate() { + for &j in read { + if component.get(j) != component.get(k) + && let (Some(w), Some(r)) = (waits.get_mut(k), readers.get_mut(j)) + { + *w += 1; + r.push(k); + } + } + } + let mut ready: BinaryHeap> = waits.iter().enumerate().filter(|(_, w)| **w == 0).map(|(k, _)| Reverse(k)).collect(); + let mut out = Vec::with_capacity(n); + while let Some(Reverse(k)) = ready.pop() { + out.push(k); + for &r in readers.get(k).map(Vec::as_slice).unwrap_or_default() { + if let Some(w) = waits.get_mut(r) { + *w = w.saturating_sub(1); + if *w == 0 { + ready.push(Reverse(r)); + } + } + } + } + // Without the reads inside loops no loop is left, so every node is placed; kept total anyway. + if out.len() < n { + let placed: HashSet = out.iter().copied().collect(); + out.extend((0..n).filter(|k| !placed.contains(k))); + } + out +} + +/// Set the calculation order (Acrobat: Set Field Calculation Order): `names` calculate first, in +/// that order, then the calculated fields left out, in their current order. `/CO` is written +/// afresh, so it lists every calculated field once and nothing else. +/// +/// Two calculated fields can share a full name in a malformed form, and the order then names +/// both: a repeated name stands for the next of them in the current order, so the current order +/// sets back unchanged. +pub fn set_calculation_order(doc: &mut Document, names: &[String]) -> Result<(), FormError> { + let all = fields(doc); + let (run, _) = run_order(doc, &all); + let mut by_name: HashMap<&str, VecDeque> = HashMap::with_capacity(run.len()); + for &(i, _) in &run { + if let Some(f) = all.get(i) { + by_name.entry(f.name.as_str()).or_default().push_back(i); + } + } + let mut chosen = HashSet::new(); + let mut co = Vec::with_capacity(names.len()); + for name in names { + let Some(next) = by_name.get_mut(name.as_str()) else { + return Err(if all.iter().any(|f| f.name == *name) { + FormError::Invalid(format!("{name} has no calculation")) + } else { + FormError::NoSuchField(name.clone()) + }); + }; + let i = next.pop_front().ok_or_else(|| FormError::Invalid(format!("{name} is in the order twice")))?; + if let Some(f) = all.get(i) { + chosen.insert(i); + co.push(Object::Ref(f.obj)); + } + } + co.extend(run.iter().filter(|(i, _)| !chosen.contains(i)).filter_map(|&(i, _)| all.get(i)).map(|f| Object::Ref(f.obj))); + write_co(doc, co) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fix order on `links` (node `k` reads `links[k]`), as places. + fn fix(links: &[Vec]) -> Vec { + fixed_order(links, &components(links)) + } + + #[test] + fn loops_are_found_without_recursion() { + // 0 ↔ 1, 2 reads itself, 3 reads 0, 4 alone. + let c = components(&[vec![1], vec![0], vec![2], vec![0], vec![]]); + assert_eq!(c[0], c[1]); + assert!(c[2] != c[0] && c[3] != c[0] && c[4] != c[0] && c[3] != c[4]); + // A chain a million fields long is one pass, not a million nested calls. + let n = 1_000_000; + let chain: Vec> = (0..n).map(|k| if k + 1 < n { vec![k + 1] } else { vec![0] }).collect(); + let c = components(&chain); + assert!(c.iter().all(|x| *x == c[0]), "one loop through every field"); + } + + #[test] + fn fix_order_moves_only_what_must_move() { + // Already right: unchanged. + assert_eq!(fix(&[vec![], vec![0], vec![1], vec![]]), [0, 1, 2, 3]); + // 0 reads 2: it moves after 2, the rest keep their order. + assert_eq!(fix(&[vec![2], vec![], vec![], vec![]]), [1, 2, 0, 3]); + // A chain written backwards comes out forwards. + assert_eq!(fix(&[vec![1], vec![2], vec![]]), [2, 1, 0]); + // A loop (0 ↔ 1) keeps its order; 2, which reads it, goes after it. + assert_eq!(fix(&[vec![1], vec![0], vec![]]), [0, 1, 2]); + assert_eq!(fix(&[vec![], vec![2], vec![1]]), [0, 1, 2]); + assert_eq!(fix(&[vec![1, 2], vec![0], vec![]]), [1, 2, 0], "a loop member still waits for what it reads outside the loop"); + // Reading itself is a loop, not a reason to move. + assert_eq!(fix(&[vec![0], vec![]]), [0, 1]); + } + + #[test] + fn a_name_finds_the_fields_under_it() { + let names = ["row.1", "row", "row.10", "rows", "row-1", "total"]; + let reads = vec![vec![], vec![], vec![], vec![], vec![], vec!["row".to_owned(), "missing".to_owned(), "row".to_owned()]]; + assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); + } + + /// Two thousand formulas each reading a group of two thousand fields would be four million + /// reads; the checks stop at their cap. + #[test] + fn reads_of_a_large_group_are_capped() { + let n = 2000; + let names: Vec = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect(); + let reads: Vec> = (0..2 * n).map(|i| if i < n { Vec::new() } else { vec!["g".to_owned()] }).collect(); + let total: usize = links(names.iter().map(String::as_str), &reads).iter().map(Vec::len).sum(); + assert_eq!(total, MAX_LINKS); + } + + proptest::proptest! { + /// Fix order is a reordering of the fields after which none reads a field that calculates + /// later, except within a loop; an order that is already right comes back unchanged. + #[test] + fn fix_order_leaves_nothing_too_early(links in proptest::collection::vec(proptest::collection::vec(0usize..12, 0..4), 1..12)) { + let n = links.len(); + let links: Vec> = links.into_iter().map(|mut l| { l.retain(|j| *j < n); l.sort_unstable(); l.dedup(); l }).collect(); + let component = components(&links); + // Components are exactly the nodes that reach each other. + let reach = |from: usize| { + let mut seen = vec![false; n]; + let mut todo = vec![from]; + while let Some(v) = todo.pop() { + for &w in &links[v] { + if !seen[w] { seen[w] = true; todo.push(w); } + } + } + seen + }; + let reaches: Vec> = (0..n).map(reach).collect(); + for a in 0..n { + for b in 0..n { + if a != b { + proptest::prop_assert_eq!(component[a] == component[b], reaches[a][b] && reaches[b][a]); + } + } + } + let fixed = fixed_order(&links, &component); + let mut sorted = fixed.clone(); + sorted.sort_unstable(); + proptest::prop_assert_eq!(sorted, (0..n).collect::>(), "a reordering"); + let mut at = vec![0; n]; + for (place, &k) in fixed.iter().enumerate() { + at[k] = place; + } + for (k, read) in links.iter().enumerate() { + for &j in read { + proptest::prop_assert!(component[j] == component[k] || at[j] < at[k], "{k} reads {j}: {fixed:?}"); + } + } + // Fixed once, the order needs no more moves. + let renumbered: Vec> = fixed.iter().map(|&k| { let mut l: Vec = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect(); + proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::>()); + } + } +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 5647d32c4f..4c924e2f97 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -19,6 +19,7 @@ mod actions; pub mod af; pub mod appearance; mod author; +mod calc_order; pub mod detect; mod scripting; pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; @@ -26,6 +27,7 @@ pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, NewField, add_field, check_style, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props, }; +pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order}; pub use scripting::{ FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script, set_field_script, @@ -920,25 +922,14 @@ pub fn recalculate(doc: &mut Document) -> Result { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { let mut all = fields(doc); - let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some(); - if !all.iter().any(calculated) { + if !all.iter().any(calc_order::is_calculated) { return Ok(0); } - let co: Vec = acroform(doc) - .and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())) - .unwrap_or_default() - .iter() - .filter_map(Object::as_ref) - .collect(); - let mut order: Vec = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect(); - for f in &all { - if calculated(f) && !order.contains(&f.name) { - order.push(f.name.clone()); - } - } + // By object: two fields can share a name in a malformed form, and each calculates. + let order: Vec = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect(); let mut changed = 0; - for name in order { - let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue }; + for obj in order { + let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue }; let lookup = |n: &str| -> Vec { let prefix = format!("{n}."); all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect() @@ -961,7 +952,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) { doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?; redraw(doc, &f, std::slice::from_ref(&v), scripts)?; - if let Some(target) = all.iter_mut().find(|x| x.name == name) { + if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; } changed += 1; diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 9a857510b9..f5b8274459 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -300,25 +300,7 @@ pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option< } })?; if key == b"C" { - let af_ref = crate::author::ensure_form(doc)?; - let mut co: Vec = doc - .get(af_ref) - .as_dict() - .and_then(|d| d.get(b"CO").cloned()) - .map(|c| doc.resolve(&c)) - .and_then(|c| c.as_array().cloned()) - .unwrap_or_default(); - co.retain(|o| o.as_ref() != Some(f.obj)); - if js.is_some() { - co.push(Object::Ref(f.obj)); - } - doc.update_dict(af_ref, |d| { - if co.is_empty() { - d.remove(b"CO"); - } else { - d.set(b"CO".to_vec(), Object::Array(co)); - } - })?; + crate::calc_order::keep_listed(doc, f.obj, js.is_some())?; } Ok(()) } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f8410bf896..b4fe48b0a6 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1126,6 +1126,159 @@ fn a_rename_reaches_every_stored_form_of_a_calculation() { ); } +/// The calculation order is read with what each formula reads and what is wrong with the order; +/// Fix order moves only what must move, and the order it writes is kept across a save. +#[test] +fn the_calculation_order_is_checked_fixed_and_kept() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{Cycle, RunsTooEarly}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "price", "shipping", "sub", "total", "grand", "tax"].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() }; + let sum = |names: &[&str]| calculate(Calculate::Simple { op: CalcOp::Sum, fields: names.iter().map(|n| n.to_string()).collect() }); + // Each calculation joins the end of the order, so this chain is written backwards. + set_props(&mut doc, "grand", &sum(&["total"])).unwrap(); + set_props(&mut doc, "total", &sum(&["sub", "shipping"])).unwrap(); + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price".into()))).unwrap(); + set_field_script(&mut doc, "tax", "calculate", Some("event.value = getField(\"total\").value * 0.2; // getField(\"grand\")")).unwrap(); + for (name, v) in [("qty", "2"), ("price", "10"), ("shipping", "5")] { + set_value(&mut doc, name, &FieldValue::Text(v.into())).unwrap(); + } + let value = |doc: &Document, name: &str| field(&fields(doc), name).value.clone(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["0".to_string()]), "grand runs too early: stale"); + let order = calculation_order(&doc, &fields(&doc)); + let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["grand", "total", "sub", "tax"]); + let issues: Vec<_> = order.fields.iter().map(|f| f.issues.clone()).collect(); + assert_eq!(issues, [vec![RunsTooEarly { field: "total".into() }], vec![RunsTooEarly { field: "sub".into() }], vec![], vec![]]); + assert_eq!(order.fields[1].reads, ["sub", "shipping"]); + assert_eq!(order.fields[3].reads, ["total"], "a name in a comment isn't read"); + assert!(order.fields.iter().all(|f| f.listed) && order.extra.is_empty()); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["sub", "total", "grand", "tax"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + recalculate(&mut doc).unwrap(); + assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["25".to_string()])); + let mut doc = reopen(&doc); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["sub", "total", "grand", "tax"], "kept across a save"); + assert!(order.fields.iter().all(|f| f.issues.is_empty()) && !order.fixable()); + // Names given first, the others after them in their order. + set_calculation_order(&mut doc, &["tax".into()]).unwrap(); + let names = |doc: &Document| calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| f.name).collect::>(); + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"]); + for (bad, error) in [ + (vec!["qty"], FormError::Invalid("qty has no calculation".into())), + (vec!["nope"], FormError::NoSuchField("nope".into())), + (vec!["sub", "sub"], FormError::Invalid("sub is in the order twice".into())), + ] { + let bad: Vec = bad.into_iter().map(String::from).collect(); + assert_eq!(set_calculation_order(&mut doc, &bad), Err(error)); + } + assert_eq!(names(&doc), ["tax", "sub", "total", "grand"], "a refused order changes nothing"); + // A loop: sub now reads grand, which reads total, which reads sub. Its new formula keeps its + // place in the order. Fix order can't fix a loop, and leaves it in its order. + set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price + grand".into()))).unwrap(); + let order = calculation_order(&doc, &fields(&doc)); + let issues: Vec<(&str, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.issues.as_slice())).collect(); + assert_eq!(issues[0], ("tax", &[RunsTooEarly { field: "total".into() }][..])); + assert_eq!( + issues[1..], + [ + ("sub", &[Cycle { fields: vec!["grand".into()] }][..]), + ("total", &[Cycle { fields: vec!["sub".into()] }][..]), + ("grand", &[Cycle { fields: vec!["total".into()] }][..]), + ] + ); + assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); +} + +/// `/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. +#[test] +fn a_calculation_order_with_gaps_and_extra_entries() { + let widget = |name: &str, y: u32, rest: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20) + }; + let sum = |names: &str| format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{names}\"\\);) >> >>"); + 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 5 0 R 6 0 R 99 0 R 12] >>".into(), + widget("a", 700, "/V (3)"), + widget("listed", 660, &sum("a")), + widget("unlisted", 620, &sum("listed")), + widget("b", 580, "/V (4)"), + ]; + let mut doc = document(&objs); + let order = calculation_order(&doc, &fields(&doc)); + let rows: Vec<(&str, bool, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.listed, f.issues.as_slice())).collect(); + assert_eq!(rows, [("listed", true, &[][..]), ("unlisted", false, &[crate::calc_order::CalculationIssue::NotInOrder][..])]); + assert_eq!(order.extra, ["a", "listed", "99 0 R", "not a reference"]); + assert!(order.fixable()); + assert_eq!(order.fixed(), ["listed", "unlisted"]); + assert_eq!(recalculate(&mut doc), Ok(2), "the unlisted field calculates too, after the listed one"); + assert_eq!(field(&fields(&doc), "unlisted").value, ["3"]); + set_calculation_order(&mut doc, &[]).unwrap(); + let doc = reopen(&doc); + let co = acroform(&doc).and_then(|af| af.get(b"CO").cloned()).unwrap(); + let refs: Vec<_> = doc.resolve(&co).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + assert_eq!(refs, [field(&fields(&doc), "listed").obj, field(&fields(&doc), "unlisted").obj], "every calculated field once, nothing else"); + assert!(!calculation_order(&doc, &fields(&doc)).fixable()); +} + +/// Two calculated fields with one full name (a malformed form: a full name is one field). The +/// order names them both, so a repeated name stands for the next of them in the current order: +/// the current order sets back unchanged, and Fix order and moves work around them. +#[test] +fn two_fields_with_one_name_keep_their_places_in_the_order() { + 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 doc = document(&objs); + // Each dup told apart by what it reads. + let rows = |doc: &Document| -> Vec<(String, Vec, bool)> { + calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| (f.name, f.reads, f.issues.is_empty())).collect() + }; + 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)]); + 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"); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fixed(), ["src", "dup", "dup"]); + set_calculation_order(&mut doc, &order.fixed()).unwrap(); + assert_eq!(rows(&doc), [row("src", "x", true), row("dup", "src", true), row("dup", "x", true)], "the dup that reads src moves after it"); + // Each dup calculates, not the first of them twice. + assert_eq!(recalculate(&mut doc), Ok(3)); + let values: Vec> = fields(&doc).into_iter().filter(|f| f.name == "dup").map(|f| f.value).collect(); + assert_eq!(values, [["3"], ["3"]]); + // A name given alone stands for the first of its fields in the current order. + set_calculation_order(&mut doc, &["dup".into()]).unwrap(); + assert_eq!(rows(&doc)[0], row("dup", "src", false)); + let thrice: Vec = ["dup", "dup", "dup"].map(String::from).into(); + assert_eq!(set_calculation_order(&mut doc, &thrice), Err(FormError::Invalid("dup is in the order twice".into()))); +} + #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 91480ac7b5..4aed1c086d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -990,6 +990,7 @@ fn is_form_edit(edit: &Edit) -> bool { | Edit::SetFieldProps { .. } | Edit::SetFieldImage { .. } | Edit::SetFieldScript { .. } + | Edit::SetCalculationOrder { .. } | Edit::ApplyScriptChanges { .. } ) } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 12e9293268..894e127be4 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1927,6 +1927,7 @@ Delete field حذف الحقل Duplicate field تكرار الحقل Set tab order ضبط ترتيب التنقل + Set calculation order تعيين ترتيب الحساب Change initial view تغيير العرض الأولي Set alternate text تعيين النص البديل Mark figure as decorative تعليم الشكل كزخرفي diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 4d6ae90a4e..e00febe68c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2043,6 +2043,7 @@ Set status Rejected Задай състояние „Отхвърлено“ Set status Unmarked Задай състояние „Неотбелязано“ Set tab order Задай реда на обхождане + Set calculation order Задаване на ред на изчисляване Set the image of {name} Задай изображението на {name} Spreadsheet (.xlsx) Електронна таблица (.xlsx) Square Квадрат diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8b0dd03fe9..292c14a614 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1974,6 +1974,7 @@ key Space Mezerník Delete field Odstranit pole Duplicate field Duplikovat pole Set tab order Nastavit pořadí tabulátoru + Set calculation order Nastavit pořadí výpočtů Change initial view Změnit úvodní zobrazení Set alternate text Nastavit alternativní text Mark figure as decorative Označit obrázek jako dekorativní diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5274c8c745..2348e7df2b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1863,6 +1863,7 @@ Delete field Feld löschen Duplicate field Feld duplizieren Set tab order Tab-Reihenfolge festlegen + Set calculation order Berechnungsreihenfolge festlegen Change initial view Anfangsansicht ändern Set alternate text Alternativtext festlegen Mark figure as decorative Abbildung als dekorativ markieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f3abed3047..c12ba3bea7 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1343,6 +1343,7 @@ Delete field Διαγραφή πεδίου Duplicate field Δημιουργία αντιγράφου πεδίου Set tab order Ορισμός σειράς μετακίνησης με Tab + Set calculation order Ορισμός σειράς υπολογισμού Change initial view Αλλαγή αρχικής προβολής Set alternate text Ορισμός εναλλακτικού κειμένου Mark figure as decorative Σήμανση σχήματος ως διακοσμητικού diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f1bee4bac..3aaea3a825 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1942,6 +1942,7 @@ action wizard Start Iniciar Delete field Eliminar campo Duplicate field Duplicar campo Set tab order Definir orden de tabulación + Set calculation order Establecer el orden de cálculo Change initial view Cambiar vista inicial Set alternate text Definir texto alternativo Mark figure as decorative Marcar figura como decorativa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5717ae203e..aaff44cb51 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1858,6 +1858,7 @@ Delete field Supprimer le champ Duplicate field Dupliquer le champ Set tab order Définir l'ordre de tabulation + Set calculation order Définir l'ordre de calcul Change initial view Modifier l'affichage initial Set alternate text Définir le texte de remplacement Mark figure as decorative Marquer la figure comme décorative diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 4d9b65860d..ff23633508 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1848,6 +1848,7 @@ Delete field Mező törlése Duplicate field Mező megkettőzése Set tab order Tab-sorrend beállítása + Set calculation order Számítási sorrend beállítása Change initial view Kezdeti nézet módosítása Set alternate text Helyettesítő szöveg beállítása Mark figure as decorative Ábra megjelölése dekoratívként diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 74771c264e..cf65e3167a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1933,6 +1933,7 @@ Delete field Elimina campo Duplicate field Duplica campo Set tab order Imposta ordine di tabulazione + Set calculation order Imposta ordine di calcolo Change initial view Modifica vista iniziale Set alternate text Imposta testo alternativo Mark figure as decorative Contrassegna figura come decorativa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9b9ed9a928..2dd0482591 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1940,6 +1940,7 @@ Delete field フィールドを削除 Duplicate field フィールドを複製 Set tab order タブ順序を設定 + Set calculation order 計算順序を設定 Change initial view 初期表示を変更 Set alternate text 代替テキストを設定 Mark figure as decorative 図を装飾として指定 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 43d0a9e4fc..e72f8c2611 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1851,6 +1851,7 @@ Delete field Өрісті жою Duplicate field Өрістің көшірмесін жасау Set tab order Қойынды ретін орнату + Set calculation order Есептеу ретін орнату Change initial view Бастапқы көріністі өзгерту Set alternate text Балама мәтінді орнату Mark figure as decorative Кескінді декоративті деп белгілеу diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 3519dddd26..981cf685c3 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2281,6 +2281,7 @@ comment menu Edit Redaguoti Set status Rejected Nustatyti būseną „Atmesta“ Set status Unmarked Nustatyti būseną „Nežymėta“ Set tab order Nustatyti tabuliavimo tvarką + Set calculation order Nustatyti skaičiavimo tvarką Set the image of {name} Nustatyti {name} paveikslėlį Shadings Spalvų perėjimai Show a field Rodyti lauką diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index fc7f9500bc..df1cd556e3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1921,6 +1921,7 @@ Delete field Usuń pole Duplicate field Powiel pole Set tab order Ustaw kolejność tabulacji + Set calculation order Ustaw kolejność obliczeń Change initial view Zmień widok początkowy Set alternate text Ustaw tekst alternatywny Mark figure as decorative Oznacz ilustrację jako dekoracyjną diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36c9c6b816..c32f9a5118 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1915,6 +1915,7 @@ Set status Rejected Definir status como Rejeitado Set status Unmarked Definir status como Não marcado Set tab order Definir ordem de tabulação + Set calculation order Definir ordem de cálculo Set the image of {name} Definir a imagem de {name} Spreadsheet (.xlsx) Planilha (.xlsx) Square Quadrado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index eee29a7121..3b5fbf628a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1928,6 +1928,7 @@ Delete field Удалить поле Duplicate field Дублировать поле Set tab order Задать порядок табуляции + Set calculation order Задать порядок вычислений Change initial view Изменить начальный вид Set alternate text Задать альтернативный текст Mark figure as decorative Пометить фигуру как декоративную diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index a6793e5e0f..cc92401df2 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1651,6 +1651,7 @@ Set status Rejected Nastavi status Zavrnjeno Set status Unmarked Nastavi status Neoznačeno Set tab order Nastavi vrstni red tabulatorja + Set calculation order Nastavi vrstni red izračunov Set the image of {name} Nastavi sliko za {name} Set to current page ({n}) Nastavi na trenutno stran ({n}) Set to zero Nastavi na nič diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index a81c5ad5e9..72c7df714f 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1847,6 +1847,7 @@ action wizard Start ప్రారంభించు Delete field ఫీల్డ్‌ను తొలగించు Duplicate field ఫీల్డ్‌ను నకలు చేయి Set tab order ట్యాబ్ క్రమాన్ని సెట్ చేయి + Set calculation order గణన క్రమాన్ని సెట్ చేయండి Change initial view ప్రారంభ వీక్షణను మార్చు Set alternate text ప్రత్యామ్నాయ టెక్స్ట్‌ను సెట్ చేయి Mark figure as decorative చిత్రాన్ని అలంకరణగా గుర్తించు diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 71f8754cbc..69546ed05d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1823,6 +1823,7 @@ Delete field Alanı sil Duplicate field Alanı çoğalt Set tab order Sekme sırasını ayarla + Set calculation order Hesaplama sırasını ayarla Change initial view İlk görünümü değiştir Set alternate text Alternatif metni ayarla Mark figure as decorative Şekli dekoratif olarak işaretle diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e44127cc80..58094f7c43 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1925,6 +1925,7 @@ Delete field Видалити поле Duplicate field Дублювати поле Set tab order Задати порядок табуляції + Set calculation order Задати порядок обчислень Change initial view Змінити початковий вигляд Set alternate text Задати альтернативний текст Mark figure as decorative Позначити рисунок як декоративний diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 32b34de010..f6e693a231 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2271,6 +2271,7 @@ comment menu Edit Sửa Set status Rejected Đặt trạng thái Đã từ chối Set status Unmarked Đặt trạng thái Chưa đánh dấu Set tab order Đặt thứ tự tab + Set calculation order Đặt thứ tự tính toán Set the image of {name} Đặt hình ảnh của {name} Shadings Chuyển sắc Show a field Hiện một trường diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3ec20a88ed..e8e91eb70c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2053,6 +2053,7 @@ Set status Rejected 将状态设为已拒绝 Set status Unmarked 将状态设为未标记 Set tab order 设置 Tab 键顺序 + Set calculation order 设置计算顺序 Set the image of {name} 设置 {name} 的图像 Spreadsheet (.xlsx) 电子表格(.xlsx) Square 正方形 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9098f14081..16a9b04aa6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1927,6 +1927,7 @@ Delete field 刪除欄位 Duplicate field 複製欄位 Set tab order 設定 Tab 順序 + Set calculation order 設定計算順序 Change initial view 變更初始檢視 Set alternate text 設定替代文字 Mark figure as decorative 將圖片標記為裝飾用途 diff --git a/docs/roadmap.md b/docs/roadmap.md index d3cd66800e..7407ecbd59 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,7 +143,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. The calculation order is read, checked, fixed and set (`form_calculation_order`): fields that calculate too early, loops, fields missing from `/CO`; Fix order moves only what must move, and an edited formula keeps its place. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -202,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. | | 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 2cfaa518e7..f165b33165 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4595,9 +4595,9 @@ tier = "P0" milestone = "M6" status = "shipped" commands = ["form.field.properties"] -tools = ["form_set_props", "form_fill"] -evidence = ["crates/forms/src/tests.rs::formats_validation_and_calculations_run_like_acrobat"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form" +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/automation.rs::the_calculation_order_is_read_fixed_and_set"] +notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. form_calculation_order reads the order with what each formula reads, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and sets it, moves one field (as the Up/Down of Acrobat's Set Field Calculation Order dialog) or fixes it (Fix order moves only what must move). A new calculation joins the end of /CO and an edited one keeps its place. The app has no way to see or change the order yet. Acrobat's dialog is known from Adobe's help summary only, not yet observed." [[feature]] id = "form.event-order" From 78ef66636d5427c24c35fe5babb0296aeba4bf52 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 20:13:46 -0400 Subject: [PATCH 16/20] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#3933) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 7513d3c3d6..92813c3a82 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -137,7 +137,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi From dbc7130b3673977c5c87acc7a1f2b2e5cac72a7f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 20:14:11 -0400 Subject: [PATCH 17/20] ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#3932) Pin third-party actions to full-length commit SHAs (with version comments) instead of mutable tags/branches to prevent supply-chain tampering via retagged releases. Resolves SonarCloud rule githubactions:S7637. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b44f032..a21cbdfd65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: EmbarkStudios/cargo-deny-action@v2 + - uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1 with: command: check From ffaf312358e4f059e9c746004308e87583407a02 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 02:30:58 +0200 Subject: [PATCH 18/20] =?UTF-8?q?M6:=20Prepare=20a=20form=20=E2=96=B8=20Ca?= =?UTF-8?q?lculation=20order:=20a=20page=20to=20reorder,=20check=20and=20f?= =?UTF-8?q?ix=20the=20order=20fields=20calculate=20in=20(#3733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M6: renaming a field renames it in the calculations that read it A rename silently broke every formula that used the field: a sum dropped it, notation read it as 0 and a script kept a stale value. set_props now rewrites the name in each field's Calculate script, in the same edit: AFSimple_Calculate field lists, simplified field notation (the BVCALC comment and its translation) and getField calls naming it in one string literal. The rest of each script is kept as written; names built at run time, non-UTF-8 script streams and a name another field still has are left alone, and a self-check keeps a script whose meaning would change. Notation is read by one lexer shared by notation_js, eval_notation and the rename. form_set_props returns formulas_updated; Field Properties says how many calculations now use the new name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form_set_props leaves the renamed field out of formulas_updated by object The renamed field was matched by the name given, but set_props stores it trimmed and under its parent's prefix. When the same call changed the field's own formula, formulas_updated listed the field as one the rename had rewritten. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: calculation order: read, check, fix and set the order fields calculate in A form's calculations run in its calculation order (/CO), which a field joins when it gets a calculation, so a chain defined in the wrong order shows stale values. calculation_order reads the order against the fields, with what each formula reads (field lists, notation names, literal getField names) and what is wrong: a field reading one that calculates later, a loop (strongly connected components, found without recursion), a field /CO leaves out, and /CO entries that calculate nothing. Fix order is a stable topological sort that moves only what must move and leaves loops alone. set_calculation_order writes /CO afresh; a name two fields share (a malformed form) stands for the next of them in the current order. Edit::SetCalculationOrder sets it in one undo step and recalculates, and form_calculation_order reads, sets, moves one field or fixes the order headlessly. An edited formula now keeps its field's place in /CO, a field /CO lists twice calculates once, and recalculation runs each field by object, so two fields sharing a name both calculate. The /CO code of the Calculate tab, field scripts and recalculation goes through one place. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: Prepare a form ▸ Calculation order: a page to reorder, check and fix the order fields calculate in The calculated fields in the order they calculate (/CO), each with its formula and the value it shows, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+Up/Down or by dragging. A card says which fields read one calculated later, which read each other in a loop and which /CO leaves out, and holds Fix order; a loop's card opens one of its formulas. On the page, arrows show what the field in focus reads and what reads it, with each linked field's step. The order is a page of the Prepare a form panel, opened from its Fields tools, with a back arrow to Prepare a form; opening it ends Preview and closes the page grid. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: form.calculation_order runs form_calculation_order headlessly The command had no tool linked, so command_list showed none for it and command_run refused it. form_calculation_order is now its tool: run without a change, it reads the order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +- ROADMAP.md | 1 + crates/automation/src/tools.rs | 1 + crates/automation/tests/automation.rs | 3 + crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/ui-egui/src/calc_order_ui.rs | 956 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 18 + crates/ui-egui/src/commands.rs | 9 + crates/ui-egui/src/i18n/ar.tsv | 36 + crates/ui-egui/src/i18n/bg.tsv | 36 + crates/ui-egui/src/i18n/cs.tsv | 36 + crates/ui-egui/src/i18n/de.tsv | 36 + crates/ui-egui/src/i18n/el.tsv | 36 + crates/ui-egui/src/i18n/es.tsv | 36 + crates/ui-egui/src/i18n/fr.tsv | 36 + crates/ui-egui/src/i18n/hu.tsv | 36 + crates/ui-egui/src/i18n/it.tsv | 36 + crates/ui-egui/src/i18n/ja.tsv | 36 + crates/ui-egui/src/i18n/kk.tsv | 36 + crates/ui-egui/src/i18n/lt.tsv | 36 + crates/ui-egui/src/i18n/pl.tsv | 36 + crates/ui-egui/src/i18n/pt-br.tsv | 36 + crates/ui-egui/src/i18n/ru.tsv | 36 + crates/ui-egui/src/i18n/sl.tsv | 36 + crates/ui-egui/src/i18n/te.tsv | 36 + crates/ui-egui/src/i18n/tr.tsv | 36 + crates/ui-egui/src/i18n/uk.tsv | 36 + crates/ui-egui/src/i18n/vi.tsv | 36 + crates/ui-egui/src/i18n/zh-hans.tsv | 36 + crates/ui-egui/src/i18n/zh-hant.tsv | 36 + crates/ui-egui/src/lib.rs | 11 +- crates/ui-egui/src/panels.rs | 13 +- crates/ui-egui/tests/prepare.rs | 154 +++++ docs/roadmap.md | 3 +- parity/acrobat-features.toml | 6 +- 36 files changed, 1980 insertions(+), 33 deletions(-) create mode 100644 crates/ui-egui/src/calc_order_ui.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index d512414448..c5e55f0631 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18d2b88bb336ec840dc460ad28ccea620a96eeea26e427cd302eb03e964e6ed2" +sha256 = "53b57cc89461ecc4d48350a4b2ef969c9ba138048a8e28d6a8ee45d549847e1f" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "14be17199efc810682bd021ec807c6488e96dd5e1c97a009f4c31fae125da0bc" +sha256 = "1d4b5c40d08e0c3ee328ffee487ff4133627381cf865d21bec682c247e6628dc" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "483c060d64d68431afd0a0eae3ef7b356cf29fcbd6a94c7242ef225c814a0077" +sha256 = "11045efab47ed4a962861f0e3e9568836a8e9ece9ea2e0a4ec58ccf78d2e677f" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7e8158add249b105d85a398dd8b5ee573f9af6631ade6e4457799d9371be747e" +sha256 = "cb4c53d7b938a5ec8eb3a001a6c385c60b84f9f1ac36240c490d164046757c2a" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4b3520cb42f50e3ac221c1ccf40e86f955e900f16f5b3251b54f80b9d068df81" +sha256 = "af4b798c568cb1bb44ef23700e2d69c1e91c7cbb81e6fb565079a797cf02c878" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b6f566905f14d7669d8c220c5cd84886abe4b6edfa8cbdd213350f4ad1923e7a" +sha256 = "8fe572236493148b23270ef5766ad1c7d65a58b293757f34772d01a44a2df026" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "383a20170ea9483c032fc40979b5b5bbd0d38b1af29b4d9153c68f2280ecb576" +sha256 = "6af18efaa6fc8d7d9e813b4411380f1f47879c3e342d800540abec5cd6cb8f81" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6f0852d52d2498b1b7713dbb67b34584f365c1ec9504964f8da28c40e1875d70" +sha256 = "2b7787232635373d44b16b93051763b01cf8fa9e6877c87c013ad42171fdc915" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a52bad8366e4911d49deb1ba0f29f8844ae24eac372a1f0040eb1a4909dc32f3" +sha256 = "9f67da0f5b455cf99be298e8318721ad6c8d306223e74ad42faf6edd2f8a839b" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c401a6505dadfa078cf45ee1c1fa834fe4aa21eb7656b641a10dd7da2613fd35" +sha256 = "f7765338632fee507a907cbabc52f72d57d903839f8a4d48b34e2cdcbb28a3bc" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a1e65941b372579a4f1bb63cac4d350620715357fd24e457a8e649ff611b5e25" +sha256 = "62517afff28af384a42aa377b86582ecddfe8dc8453f06c481246e96184add59" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "518c3db5cfef1bd1f3cd6249b2faf783b2301d2a77931315a77e28bfab8250e3" +sha256 = "d46ff4a0505c6374008d55fc4112d4a41508c333411551dad8f6102243bbcccd" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c22f0dab633f832d0ed9d7ad9c97f5d1b636505144593917fc18bc4dff40458c" +sha256 = "61f9efbc72c1e597cdb9a2892bb87e4d0b64c1a7736bc8f7d719e9f18c85da12" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "90226cb9e54441a926da35eeaa3f8272dcf0b7da4e269ba5ccb37c0404291778" +sha256 = "90fac37b3f4c69d16841fec2bc30b66aa3cd0964a493ebdac4f71e40dc424164" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e0874fddf6833e6005604dbc16f4b9dbb72d07cff3ce18bb8cbece0fee2fc5e0" +sha256 = "18c15dd98f6efe9929d6b00360dc6d15a44bbe6bfde044688bc911011868efeb" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1ae379d3bb19741c50e05a967f3b33f73d34df0b23a55257a04524c917c0a53d" +sha256 = "07b222d5b3fdd155ba57cec323dc9ca290c6e01331bb32f7181f21728362d53b" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6efd057033876643b4a4a5a9179ad9c4291f6201c04c2cc7948f4d454e6f90e1" +sha256 = "b15b5fdca526de6d39d4a22b0c4d54f13eb5df0691ef6c0ea5e4987122ea45be" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b929e99180a0fa23cdfb8c8fa77f11e458b9ef9adc269890792ac066f0f0713c" +sha256 = "882c07204a95bef324f67f891e76dae0a1a499a420e07929490e6b7f62cf4487" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f46a9aa4c7b34ef97d5f0fa7dc93ce36cdece9498a8bf51cd83fecf002b98b6e" +sha256 = "7afdd36b96ac5b9c4c1023f506b98af9c559fe85ea00eb7e9518eda9fe77850b" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "49275046f26850714e87a4c862cd0cac8eee175a39ec839f6b348e1e3ccc6bef" +sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "31fbec695e02ea651daae01f2673a5dadfc1430080f2adb2c6d441bc06900cf4" +sha256 = "2c352743eaeec21eb9e522c71c43f3f6df34b81f024a5c72c85332e2ecc2b338" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cd4d613d1e8d9b5cc1bc82c5a9c35968048319027b0470d1c644c6a3bc617243" +sha256 = "a212a56fd316e58349845c750e935b28ba5074b3fd096016c3ae92cbe502648f" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index 7cb7c17dc3..96f6ede4b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -172,6 +172,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cd8694e1d2..59ecf04252 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -411,6 +411,7 @@ pub fn tools() -> Vec { &["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.") + .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" } }), &["doc"], diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 5bd3a758f9..2c87162d5d 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3889,6 +3889,9 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!(r["fields"][3]["issues"], json!([{ "issue": "cycle", "fields": ["y"] }])); assert_eq!((r["fixable"].clone(), r["extra"].clone()), (json!(true), json!([]))); assert!(r.get("undo").is_none(), "reading changes nothing"); + // Prepare a form ▸ Calculation order is this tool headlessly. + let r = ok(&mut a, "command_run", json!({ "id": "form.calculation_order", "params": { "doc": doc } })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "fix": true })); assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "x", "y"]), "only what must move moves; the loop stays"); assert_eq!(r["undo"], "Set calculation order"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3d2ba30820..551cbdc67c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -347,6 +347,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Field properties", "settings-2", "form.field.properties", Ready), item("Tab order by rows", "rows-3", "form.tab_order.row", Ready), item("Tab order by columns", "columns-3", "form.tab_order.column", Ready), + item("Calculation order", "list-ordered", "form.calculation_order", Ready), item("Flatten form fields", "layers", "form.flatten", Ready), item("Import data", "file-input", "form.import_data", Ready), item("Export data", "file-output", "form.export_data", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fcd56efee4..9958763d19 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -275,6 +275,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"), c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"), c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"), + c("form.calculation_order", "Calculation order", None, None, Document, "list-ordered"), c("edit.text", "Add text", None, None, Modification, "type"), c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"), c("edit.link", "Add or edit links", None, None, Modification, "link-2"), diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs new file mode 100644 index 0000000000..a9a726d241 --- /dev/null +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -0,0 +1,956 @@ +//! 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 +//! the selected field (Acrobat's Set Field Calculation Order has Up and Down), or with Alt+↑/↓. +//! Every change is one undoable edit. +//! +//! The page: each calculated field's step in the order, and for the field in focus (under the +//! pointer in the list or on the page, else the selected one) arrows from the fields it reads and +//! to the fields that read it. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Vec2, pos2, vec2}; +use pdfcraft_engine::form_scripts::{CalcOp, Calculate, format_value}; +use pdfcraft_engine::{CalculatedField, CalculationIssue, CalculationOrder, Edit, FormField, FormFieldKind}; +use pdfcraft_render::DocInfo; + +use crate::canvas::PageXform; +use crate::theme::{self, Tokens}; +use crate::{LeftPanel, PdfCraftApp, icons, widgets}; + +/// Links and steps on the page, which is paper white in either theme. +const LINK_OK: Color32 = Color32::from_rgb(0x1B, 0x63, 0xE0); +const LINK_LATE: Color32 = Color32::from_rgb(0xA8, 0x64, 0x00); +const LINK_LOOP: Color32 = Color32::from_rgb(0xC2, 0x26, 0x2E); +const STEP: Color32 = Color32::from_rgb(0x3A, 0x3A, 0x42); +const ROW: f32 = 46.0; +/// Links kept at most: a formula reading a group reads every field under it. +const MAX_LINKS: usize = 20_000; +/// Names a sentence lists before it stops with "…". +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; + +/// How a field's place in the order, or a link between two fields, stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tone { + Ok, + /// 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. + Loop, +} + +/// A view's calculation order, read again only after an edit (filling a field in is one), and +/// what the pointer is over. +#[derive(Clone, Debug, Default)] +pub struct CalcOrderView { + cache: Option<(u64, Arc)>, + /// The field whose row is under the pointer, this frame. + hover_row: Option, + /// The field under the pointer on the page, last frame. + 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 + /// its row into view once. + scrolled_to: Option, +} + +/// The calculation order with what the panel and the page show of it. +#[derive(Debug)] +struct Listed { + order: CalculationOrder, + /// One per field of the order, in its order. + rows: Vec, + /// From each field a formula reads to the calculated field that reads it. + links: Vec, +} + +#[derive(Debug)] +struct Row { + /// The value as the field shows it (its format applied), on one line. + value: String, + /// The formula on one line: the Calculate tab's, or a script's first line. + formula: String, + script: bool, + tone: Tone, +} + +#[derive(Clone, Debug, PartialEq)] +struct Link { + from: String, + to: String, + tone: Tone, +} + +impl CalcOrderView { + fn listed(&mut self, doc: &pdfcraft_engine::Document) -> Arc { + let generation = doc.edit_generation(); + if let Some(listed) = self.current(generation) { + return listed; + } + let order = doc.calculation_order(); + let rows = rows(&order.fields, &doc.form); + let names: Vec<&str> = doc.form.iter().map(|f| f.name.as_str()).collect(); + let links = links(&order.fields, names); + let listed = Arc::new(Listed { order, rows, links }); + self.cache = Some((generation, listed.clone())); + listed + } + + /// The order read at `generation`, if it was. + fn current(&self, generation: u64) -> Option> { + self.cache.as_ref().filter(|(g, _)| *g == generation).map(|(_, listed)| listed.clone()) + } + + /// 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; + } + + /// 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 tone(issues: &[CalculationIssue]) -> Tone { + if issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { .. })) { + Tone::Loop + } else if issues.iter().any(|i| matches!(i, CalculationIssue::RunsTooEarly { .. })) { + Tone::Late + } else { + Tone::Ok + } +} + +/// `text` on one line, cut at 200 characters. +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. +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); + } + calculated + .iter() + .map(|c| { + let field = by_name.get(c.name.as_str()); + 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(), + None => String::new(), + }; + let (formula, script) = match field.map(|f| (&f.actions.calculate, f.actions.scripts.calculate.as_deref())) { + Some((Calculate::Simple { op, fields }, _)) => { + let op = match op { + CalcOp::Sum => "sum", + CalcOp::Product => "product", + CalcOp::Average => "average", + CalcOp::Minimum => "min", + CalcOp::Maximum => "max", + }; + (format!("= {op}({})", fields.join(", ")), false) + } + Some((Calculate::Notation(n), _)) => (format!("= {}", one_line(n)), false), + Some((Calculate::None, Some(js))) => (js.lines().map(str::trim).find(|l| !l.is_empty()).map(one_line).unwrap_or_default(), true), + _ => (String::new(), false), + }; + Row { value: one_line(&value), formula, script, tone: tone(&c.issues) } + }) + .collect() +} + +/// The links between the form's fields (`names`): a formula's read name finds the field of that +/// name and the fields under it, as the calculation looks values up. At most [`MAX_LINKS`]. +fn links(calculated: &[CalculatedField], mut names: Vec<&str>) -> Vec { + names.sort_unstable(); + names.dedup(); + let mut out = Vec::new(); + for c in calculated { + let mut seen = HashSet::new(); + for read in &c.reads { + let prefix = format!("{read}."); + let exact = names.partition_point(|n| *n < read.as_str()); + let under = names.partition_point(|n| *n < prefix.as_str()); + let same = names.get(exact..).unwrap_or_default().iter().take_while(|n| **n == read.as_str()); + let below = names.get(under..).unwrap_or_default().iter().take_while(|n| n.starts_with(&prefix)); + for &from in same.chain(below) { + if out.len() >= MAX_LINKS { + return out; + } + if !seen.insert(from) { + continue; + } + let tone = if c.issues.iter().any(|i| matches!(i, CalculationIssue::Cycle { fields } if fields.iter().any(|x| x == from))) { + Tone::Loop + } else if c.issues.iter().any(|i| matches!(i, CalculationIssue::RunsTooEarly { field } if field == from)) { + Tone::Late + } else { + Tone::Ok + }; + out.push(Link { from: from.to_owned(), to: c.name.clone(), tone }); + } + } + } + out +} + +/// The loops: the fields that read each other, each loop in the order's order. +fn loops(calculated: &[CalculatedField]) -> Vec> { + let index: HashMap<&str, usize> = calculated.iter().enumerate().map(|(k, f)| (f.name.as_str(), k)).collect(); + // Each field points at an earlier field of its loop, and a loop's first field at itself. + let mut first: Vec = (0..calculated.len()).collect(); + let root = |first: &[usize], mut k: usize| { + // Every step goes to an earlier field, so there are fewer steps than fields. + for _ in 0..first.len() { + match first.get(k) { + Some(&p) if p != k => k = p, + _ => break, + } + } + k + }; + for (k, f) in calculated.iter().enumerate() { + for issue in &f.issues { + if let CalculationIssue::Cycle { fields } = issue { + for j in fields.iter().filter_map(|x| index.get(x.as_str()).copied()) { + let (a, b) = (root(&first, k), root(&first, j)); + if let Some(p) = first.get_mut(a.max(b)) { + *p = a.min(b); + } + } + } + } + } + let mut groups: Vec<(usize, Vec<&str>)> = Vec::new(); + for (k, f) in calculated.iter().enumerate().filter(|(_, f)| tone(&f.issues) == Tone::Loop) { + let r = root(&first, k); + match groups.iter_mut().find(|(g, _)| *g == r) { + Some((_, names)) => names.push(&f.name), + None => groups.push((r, vec![&f.name])), + } + } + groups.into_iter().map(|(_, names)| names).collect() +} + +/// Names in a sentence: "a, b and c", cut with "…" after [`MAX_NAMES`]. +fn names(list: &[&str]) -> String { + match list { + [] => String::new(), + [one] => (*one).to_owned(), + _ if list.len() > MAX_NAMES => format!("{}, …", list.iter().take(MAX_NAMES).copied().collect::>().join(", ")), + [rest @ .., last] => crate::i18n::fmt(tl!("{names} and {last}"), &[("names", &rest.join(", ")), ("last", *last)]), + } +} + +/// 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 { + 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()) { + reads.push(&l.from); + } + if l.from == focus && !read_by.contains(&l.to.as_str()) { + read_by.push(&l.to); + } + } + let reads = if reads.is_empty() { + crate::i18n::fmt(tl!("{field} reads no other field"), &[("field", focus)]) + } else { + crate::i18n::fmt(tl!("{field} reads {fields}"), &[("field", focus), ("fields", &names(&reads))]) + }; + let read_by = 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"); + crate::i18n::fmt(&template, &[("fields", &names(&read_by))]) + }; + format!("{reads} · {read_by}") +} + +/// The colours of the cards and the steps in the list, for the theme. +struct Palette { + warn: (Color32, Color32, Color32, Color32), + good: (Color32, Color32, Color32), + looped: (Color32, Color32, Color32, Color32), + /// A step's fill and number, and the caption colour, by tone. + step_ok: (Color32, Color32), + step_late: (Color32, Color32), + step_loop: (Color32, Color32), +} + +impl Palette { + /// Fill, border, title and text of each card; light and dark pass 4.5:1 for their text. + fn new(dark: bool) -> Self { + let c = Color32::from_rgb; + if dark { + Palette { + warn: (c(0x3A, 0x2E, 0x17), c(0x6B, 0x53, 0x21), c(0xF2, 0xC2, 0x6B), c(0xE6, 0xD7, 0xB8)), + good: (c(0x1D, 0x33, 0x26), c(0x2F, 0x5C, 0x40), c(0x8F, 0xD6, 0xA8)), + looped: (c(0x3D, 0x20, 0x22), c(0x6E, 0x32, 0x36), c(0xF1, 0x9A, 0x9E), c(0xE9, 0xC9, 0xCB)), + step_ok: (c(0x3A, 0x3A, 0x40), c(0xD4, 0xD4, 0xDA)), + step_late: (c(0x4A, 0x3A, 0x1A), c(0xF2, 0xC2, 0x6B)), + step_loop: (c(0x4A, 0x22, 0x24), c(0xF1, 0x9A, 0x9E)), + } + } else { + Palette { + warn: (c(0xFF, 0xF6, 0xE5), c(0xF2, 0xD9, 0xA6), c(0x8A, 0x51, 0x00), c(0x4A, 0x3A, 0x1E)), + good: (c(0xEE, 0xF8, 0xF1), c(0xBF, 0xE3, 0xCC), c(0x1E, 0x6B, 0x3C)), + looped: (c(0xFD, 0xF0, 0xF0), c(0xF1, 0xC4, 0xC6), c(0xA8, 0x1E, 0x25), c(0x5A, 0x2A, 0x2D)), + step_ok: (c(0xED, 0xED, 0xF0), c(0x3A, 0x3A, 0x42)), + step_late: (c(0xFF, 0xF0, 0xD6), c(0x8A, 0x51, 0x00)), + step_loop: (c(0xFD, 0xE8, 0xE8), c(0xC2, 0x26, 0x2E)), + } + } + } + + fn step(&self, tone: Tone) -> (Color32, Color32) { + match tone { + Tone::Ok => self.step_ok, + Tone::Late => self.step_late, + Tone::Loop => self.step_loop, + } + } +} + +/// A field being dragged to a new place: its place now. +#[derive(Clone, Copy)] +struct Dragged(usize); + +/// What the page asks for, done once it is drawn. +enum Act { + Order(Vec), + Undo, + EditFormula(String), + Select(String), +} + +/// A card: a tinted, outlined box as wide as the panel. +fn card(ui: &mut egui::Ui, fill: Color32, edge: Color32, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + egui::Frame::new() + .fill(fill) + .stroke(Stroke::new(1.0, edge)) + .corner_radius(CornerRadius::same(10)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 6.0; + add(ui) + }) + .inner +} + +/// A card's first line: its icon and title. +fn card_title(ui: &mut egui::Ui, icon: &str, title: &str, colour: Color32) { + ui.horizontal_top(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 17.0), Sense::hover()); + icons::paint(ui, r, icon, 15.0, colour); + ui.add(egui::Label::new(egui::RichText::new(title).font(theme::semibold(13.0)).color(colour)).wrap()); + }); +} + +fn card_text(ui: &mut egui::Ui, text: &str, colour: Color32) { + ui.add(egui::Label::new(egui::RichText::new(text).font(theme::regular(12.0)).color(colour)).wrap()); +} + +/// A word that acts: "Undo", "Edit total's formula". +fn text_button(ui: &mut egui::Ui, text: &str, t: &Tokens) -> bool { + let resp = + ui.add(egui::Label::new(egui::RichText::new(text).font(theme::semibold(12.5)).color(t.accent_text)).selectable(false).sense(Sense::click())); + theme::hand(resp).clicked() +} + +/// The cards above the list: what is wrong and Fix order, or that the order is right; and loops. +fn cards(ui: &mut egui::Ui, t: &Tokens, pal: &Palette, order: &CalculationOrder, editable: bool, undo: bool) -> Option { + let lang = crate::i18n::current(); + let mut act = None; + // Each field that calculates too early, with the fields it reads that calculate later. + let late: Vec<(&str, Vec<&str>)> = order + .fields + .iter() + .map(|f| { + let later = f.issues.iter().filter_map(|i| if let CalculationIssue::RunsTooEarly { field } = i { Some(field.as_str()) } else { None }); + (f.name.as_str(), later.collect::>()) + }) + .filter(|(_, later)| !later.is_empty()) + .collect(); + let unlisted: Vec<&str> = order.fields.iter().filter(|f| !f.listed).map(|f| f.name.as_str()).collect(); + let loops = loops(&order.fields); + if order.fixable() { + let (fill, edge, title, text) = pal.warn; + card(ui, fill, edge, |ui| { + let heading = if late.is_empty() { + tl!("The order saved in the PDF needs tidying").to_owned() + } else { + 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) { + let template = crate::i18n::trn( + lang, + later.len() as u64, + "{field} calculates before {fields}, which it reads.", + "{field} calculates before {fields}, which it reads.", + ); + 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; + 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() { + let template = crate::i18n::trn( + lang, + unlisted.len() as u64, + "{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.", + ); + card_text(ui, &crate::i18n::fmt(&template, &[("fields", &names(&unlisted))]), text); + } + if !order.extra.is_empty() { + let note = crate::i18n::trn( + lang, + order.extra.len() as u64, + "The calculation order has {n} entry that calculates nothing. Fix order removes it.", + "The calculation order has {n} entries that calculate nothing. Fix order removes them.", + ); + card_text(ui, ¬e, text); + } + ui.add_space(2.0); + ui.horizontal(|ui| { + let fix = ui.add_enabled_ui(editable, |ui| widgets::pill_button(ui, tl!("Fix order"), true)).inner; + if fix.on_hover_text(tl!("Move only what must move, so every field calculates after the fields it reads.")).clicked() { + act = Some(Act::Order(order.fixed())); + } + 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 { + let (fill, edge, colour) = pal.good; + card(ui, fill, edge, |ui| { + let said = if loops.is_empty() { + tl!("Every field calculates after the fields it reads.") + } else { + tl!("Every field outside a loop calculates after the fields it reads.") + }; + card_title(ui, "check", said, colour); + if undo && text_button(ui, tl!("Undo"), t) { + act = Some(Act::Undo); + } + }); + } + for members in loops.iter().take(MAX_LOOPS) { + let (fill, edge, title, text) = pal.looped; + ui.add_space(6.0); + card(ui, fill, edge, |ui| { + let heading = match members.as_slice() { + [one] => crate::i18n::fmt(tl!("{field} reads its own value"), &[("field", *one)]), + _ => crate::i18n::fmt(tl!("{fields} read each other"), &[("fields", &names(members))]), + }; + card_title(ui, "rotate-cw", &heading, title); + card_text(ui, tl!("No order can fix a loop. Change one of the formulas."), text); + if editable + && let Some(first) = members.first() + && text_button(ui, &crate::i18n::fmt(tl!("Edit {field}'s formula"), &[("field", first)]), t) + { + act = Some(Act::EditFormula((*first).to_owned())); + } + }); + } + act +} + +/// Why a row is marked, for its tooltip. +fn explained(issues: &[CalculationIssue]) -> String { + issues + .iter() + .map(|i| match i { + CalculationIssue::RunsTooEarly { field } => { + crate::i18n::fmt(tl!("Reads {name}, which calculates later, so it shows an old value."), &[("name", field)]) + } + CalculationIssue::Cycle { fields } => crate::i18n::fmt( + tl!("Reads {names}, which read it back. No order can fix a loop: change one of the formulas."), + &[("names", &fields.join(", "))], + ), + CalculationIssue::NotInOrder => { + tl!("Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it.").to_owned() + } + }) + .collect::>() + .join("\n") +} + +/// The Calculation order page; Back returns to Prepare a form. +pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { + let (back, close) = crate::panels::panel_header(ui, t, "Calculation order", Some(tl!("Back to Prepare a form"))); + if back { + app.left = LeftPanel::Tool("form"); + } + if close { + app.left_open = false; + } + let Some((index, id)) = app.active_ids() else { + ui.label(egui::RichText::new(tl!("Open a document first")).color(t.text_muted)); + return; + }; + let Some(doc) = app.session.get(id) else { return }; + let Some(view) = app.views.get_mut(index) else { return }; + let listed = view.calc_order.listed(doc); + let order = &listed.order; + if order.fields.is_empty() && order.extra.is_empty() { + ui.label( + egui::RichText::new(tl!( + "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." + )) + .color(t.text_muted), + ); + return; + } + let editable = doc.allows_modification(); + let pal = Palette::new(ui.visuals().dark_mode); + let mut act = cards(ui, t, &pal, order, editable, doc.can_undo() == Some("Set calculation order")); + + widgets::section_title(ui, tl!("Order · top calculates first")); + let mut links_on = !view.calc_order.hide_links; + + let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect(); + let moved = |from: usize, to: usize| -> Option { + let mut next: Vec = names.iter().map(|n| (*n).to_owned()).collect(); + (from != to && from < next.len() && to < next.len()).then(|| { + let name = next.remove(from); + next.insert(to, name); + Act::Order(next) + }) + }; + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.clone()); + let place = selected.as_deref().and_then(|s| names.iter().position(|n| *n == s)); + // Alt+↑/↓ move the selected field, as in Combine files. + let ctx = ui.ctx().clone(); + if editable + && let Some(k) = place + && !ctx.text_edit_focused() + && !egui::Popup::is_any_open(&ctx) + && ctx.memory(|m| m.top_modal_layer().is_none()) + { + let (up, down) = + ctx.input_mut(|i| (i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowUp), i.consume_key(egui::Modifiers::ALT, egui::Key::ArrowDown))); + if up && let Some(a) = k.checked_sub(1).and_then(|to| moved(k, to)) { + act = Some(a); + } else if down && let Some(a) = moved(k, k + 1) { + act = Some(a); + } + } + + let focus_page = view.calc_order.hover_page.clone(); + // A field selected since the last frame, whose row the list shows. + let reveal = (selected != view.calc_order.scrolled_to).then(|| selected.clone()).flatten(); + view.calc_order.scrolled_to = selected.clone(); + let mut hover_row = None; + let count = names.len(); + egui::ScrollArea::vertical().id_salt(("calc-order", id)).auto_shrink([false, false]).show(ui, |ui| { + for (k, (f, row)) in order.fields.iter().zip(&listed.rows).enumerate() { + let sense = if editable { Sense::click_and_drag() } else { Sense::click() }; + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), sense); + let is_selected = selected.as_deref() == Some(f.name.as_str()); + resp.widget_info(|| { + let label = if row.value.is_empty() { format!("{}. {}", k + 1, f.name) } else { format!("{}. {}: {}", k + 1, f.name, row.value) }; + egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, label) + }); + if reveal.as_deref() == Some(f.name.as_str()) { + resp.scroll_to_me(Some(egui::Align::Center)); + } + let pointed = ui.rect_contains_pointer(rect); + if pointed { + hover_row = Some(f.name.clone()); + } + 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()) { + ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); + } + press.wash(ui, rect, 8, is_selected); + let body = rect.translate(press.offset()); + let (fill, number) = pal.step(row.tone); + let step = pos2(body.left() + 19.0, body.center().y); + ui.painter().circle_filled(step, 11.0, fill); + ui.painter().text(step, Align2::CENTER_CENTER, (k + 1).to_string(), theme::semibold(11.0), number); + let top = body.top() + 15.0; + let bottom = body.top() + 31.0; + let text_x = body.left() + 38.0; + let value_end = arrows_x - 4.0; + let caption = match row.tone { + 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 => None, + }; + let value_font = theme::medium(13.0); + let value = widgets::fit_line(&row.value, (value_end - text_x) * 0.4, |s| widgets::text_width(ui, s, &value_font)); + let value_w = widgets::text_width(ui, &value, &value_font); + let value_colour = if row.tone == Tone::Late { pal.step_late.1 } else { t.text }; + ui.painter().text(pos2(value_end, top), Align2::RIGHT_CENTER, value, value_font, value_colour); + let caption_w = caption.map_or(0.0, |(c, colour)| { + let font = theme::semibold(10.5); + let w = widgets::text_width(ui, c, &font); + 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); + 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); + widgets::paint_left(ui, pos2(text_x, top), &f.name, name_font.clone(), t.text, name_room); + if let Some((label, w)) = tag { + let x = text_x + widgets::text_width(ui, &f.name, &name_font).min(name_room) + 6.0; + let chip = Rect::from_min_size(pos2(x, top - 7.5), vec2(w, 15.0)); + 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); + } + // The selected field's arrows, or the one under the pointer. + if editable && (is_selected || pointed) { + let up = Rect::from_min_size(pos2(arrows_x, rect.top() + 3.0), vec2(24.0, 19.0)); + let down = Rect::from_min_size(pos2(arrows_x, rect.bottom() - 22.0), vec2(24.0, 19.0)); + for (r, icon, earlier, tip) in + [(up, "chevron-up", true, tl!("Earlier in calculation order")), (down, "chevron-down", false, tl!("Later in calculation order"))] + { + let to = if earlier { k.checked_sub(1) } else { Some(k + 1).filter(|&to| to < count) }; + let b = ui.interact(r, ui.id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); + b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, to.is_some(), format!("{tip}: {}", f.name))); + if visible { + let bpress = theme::Press::track(ui, &b); + bpress.wash(ui, r, 4, false); + icons::paint(ui, r.shrink(2.5).translate(bpress.offset()), icon, 14.0, if to.is_some() { t.icon } else { t.text_faint }); + } + if theme::hand(b).on_hover_text(tip).clicked() + && let Some(a) = to.and_then(|to| moved(k, to)) + { + act = Some(a); + } + } + } + if editable { + resp.dnd_set_drag_payload(Dragged(k)); + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } + // A field dragged over this one goes where the line is drawn. + if let Some(from) = resp.dnd_hover_payload::().map(|d| d.0) + && from != k + { + let y = if from > k { rect.top() } else { rect.bottom() }; + ui.painter().hline(rect.x_range(), y, Stroke::new(2.0, t.accent)); + } + if let Some(from) = resp.dnd_release_payload::().map(|d| d.0) + && let Some(a) = moved(from, k) + { + act = Some(a); + } + } + let resp = theme::hand(resp); + // The whole formula, then what is wrong; written only while the pointer is on it. + let resp = if resp.hovered() { + let why = [row.formula.clone(), explained(&f.issues)].into_iter().filter(|s| !s.is_empty()).collect::>().join("\n\n"); + if why.is_empty() { resp } else { resp.on_hover_text(why) } + } else { + resp + }; + if resp.clicked() { + act = Some(Act::Select(f.name.clone())); + } + } + ui.add_space(8.0); + ui.add(widgets::checkbox(&mut links_on, egui::RichText::new(tl!("Links on page")).font(theme::regular(12.0)).color(t.text_muted))) + .on_hover_text(tl!("Show on the page which fields each field reads")); + if editable { + ui.add_space(4.0); + let (up, down) = if cfg!(target_os = "macos") { ("⌥↑", "⌥↓") } else { ("Alt+↑", "Alt+↓") }; + let hint = crate::i18n::fmt(tl!("Drag a field to move it, or select it and press {up} or {down}."), &[("up", up), ("down", down)]); + ui.add(egui::Label::new(egui::RichText::new(hint).font(theme::regular(11.5)).color(t.text_faint)).wrap()); + } + }); + view.calc_order.hover_row = hover_row; + view.calc_order.hide_links = !links_on; + match act { + Some(Act::Order(names)) => { + app.apply_edit(Edit::SetCalculationOrder { names }); + } + Some(Act::Undo) => { + app.execute("edit.undo"); + } + Some(Act::EditFormula(name)) => { + if let Some(view) = app.views.get_mut(index) { + view.prepare.select((name.clone(), 0), false); + } + app.open_field_props(&name, 0); + if let Some(d) = app.field_props.as_mut() { + d.tab = crate::prepare::FieldTab::Calculate; + } + } + Some(Act::Select(name)) => { + // Where its first widget is, to show it there. + let shown = app + .session + .get(id) + .and_then(|doc| doc.form.iter().find(|g| g.name == name).and_then(|g| g.widgets.first()).and_then(|w| Some((w.page?, w.rect)))); + if let Some(view) = app.views.get_mut(index) { + view.prepare.select((name, 0), false); + if let Some((page, rect)) = shown { + view.go_to_page(page); + view.flash = Some((page, rect.map(|v| v as f32), 0.0)); + } + } + } + None => {} + } +} + +/// 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 +/// readable either way. Returns the field under the pointer. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_page( + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &CalcOrderView, + generation: u64, + selected: Option<&str>, +) -> Option { + let listed = view.current(generation)?; + // Each field's first widget on this page. + let mut on_page: Vec<(&str, 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]))); + } + } + if on_page.is_empty() { + return None; + } + let pointer = ui.input(|i| i.pointer.hover_pos()); + 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 zoom = (xf.rect.width() / xf.pw.max(1.0)).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) { + 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 }; + 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())) { + 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()), + None => row.tone != Tone::Ok || !f.listed, + }; + if !shown { + continue; + } + let fill = match row.tone { + Tone::Ok => STEP, + Tone::Late => LINK_LATE, + Tone::Loop => 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)); + let c = r.left_top(); + painter.circle(c, radius, fill, Stroke::new(1.5, Color32::WHITE)); + painter.text(c, Align2::CENTER_CENTER, (k + 1).to_string(), theme::semibold(radius * 1.1), Color32::WHITE); + } + hovered +} + +/// 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; + let along = |h: f32, d: f32| if d.abs() > 1e-6 { h / d.abs() } else { f32::INFINITY }; + r.center() + dir * along(half.x, dir.x).min(along(half.y, dir.y)) +} + +/// An arrow from field `a` to field `b`: dashed when the value arrives too late, red in a loop. +/// Fields one above the other link out to the right, and fields side by side over the top, clear +/// of the fields between them (a grid's neighbours would otherwise get arrows too short to see). +/// Other fields link between their nearest edges, bent a little to the arrow's left so the two +/// arrows of a loop don't overlap. A field that reads itself gets a loop off its right edge. +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, + }; + let stroke = Stroke::new(1.75, colour); + let head = 9.0; + let gap = b.center() - a.center(); + let overlap = |a0: f32, a1: f32, b0: f32, b1: f32| a1.min(b1) - a0.max(b0); + let (start, c1, c2, end) = if gap.length() < 1.0 { + let bow = 24.0 * zoom; + let (start, end) = (pos2(a.right(), a.center().y - 4.0), pos2(a.right(), a.center().y + 4.0)); + (start, start + vec2(bow, -bow * 0.6), end + vec2(bow, bow * 0.6), end) + } else if overlap(a.left(), a.right(), b.left(), b.right()) > 0.0 { + let (start, end) = (pos2(a.right() + 2.0, a.center().y - 3.0), pos2(b.right() + 2.0, b.center().y + 3.0)); + let bow = (18.0 * zoom + (end.y - start.y).abs() * 0.3).min(140.0); + (start, start + vec2(bow, 0.0), end + vec2(bow, 0.0), end) + } else if overlap(a.top(), a.bottom(), b.top(), b.bottom()) > 0.0 { + let (start, end) = (pos2(a.center().x - 3.0, a.top() - 2.0), pos2(b.center().x + 3.0, b.top() - 2.0)); + let bow = (14.0 * zoom + (end.x - start.x).abs() * 0.25).min(100.0); + (start, start - vec2(0.0, bow), end - vec2(0.0, bow), end) + } else { + let dir = gap / gap.length(); + let (start, end) = (edge(a, dir) + dir * 2.0, edge(b, -dir) - dir * 2.0); + let bend = vec2(dir.y, -dir.x) * ((end - start).length() * 0.18).min(60.0); + (start, start + (end - start) / 3.0 + bend, start + (end - start) * (2.0 / 3.0) + bend, end) + }; + // The line stops where the head starts, which points along the curve's last stretch. + let back = (end - c2).normalized(); + let back = if back.is_finite() && back != Vec2::ZERO { back } else { vec2(-1.0, 0.0) }; + let base = end - back * head; + let curve = egui::epaint::CubicBezierShape::from_points_stroke([start, c1, c2, base], false, Color32::TRANSPARENT, stroke); + let points: Vec = curve.flatten(Some(0.5)); + if tone == Tone::Late { + painter.extend(Shape::dashed_line(&points, stroke, 5.0, 4.0)); + } else { + painter.add(Shape::line(points, stroke)); + } + let side = vec2(-back.y, back.x) * 4.5; + painter.add(Shape::convex_polygon(vec![end, base + side, base - side], colour, Stroke::NONE)); +} + +/// The focused field's links in words, in a pill at the top of the page view. +pub(crate) fn paint_trace(ui: &egui::Ui, painter: &egui::Painter, viewport: Rect, view: &CalcOrderView, generation: u64, selected: Option<&str>) { + if view.hide_links { + return; + } + 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 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); + let shown = widgets::fit_line(&text, room, |s| widgets::text_width(ui, s, &font)); + if shown.is_empty() { + return; + } + let w = widgets::text_width(ui, &shown, &font) + 46.0; + let pill = Rect::from_center_size(pos2(viewport.center().x, viewport.top() + 26.0), vec2(w, 30.0)); + painter.rect(pill, CornerRadius::same(15), t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui, Rect::from_center_size(pill.left_center() + vec2(18.0, 0.0), vec2(14.0, 14.0)), "move-right", 13.0, LINK_OK); + painter.text(pill.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, shown, font, t.text); +} + +#[cfg(test)] +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 } + } + + #[test] + fn a_name_list_reads_as_a_sentence_and_stops_after_six() { + crate::i18n::set_current(crate::i18n::Lang::EN); + assert_eq!(names(&[]), ""); + assert_eq!(names(&["a"]), "a"); + assert_eq!(names(&["a", "b"]), "a and b"); + assert_eq!(names(&["a", "b", "c"]), "a, b and c"); + assert_eq!(names(&["1", "2", "3", "4", "5", "6", "7"]), "1, 2, 3, 4, 5, 6, …"); + } + + fn link(from: &str, to: &str, tone: Tone) -> Link { + Link { from: from.into(), to: to.into(), tone } + } + + #[test] + fn a_read_name_links_the_field_of_that_name_and_the_fields_under_it() { + let calculated = [field("total", &["row", "row.1", "tax"], vec![])]; + let got = links(&calculated, vec!["rows", "row.2", "total", "row", "row.1", "tax"]); + let from: Vec<&str> = got.iter().map(|l| l.from.as_str()).collect(); + // `rows` isn't under `row`, and `row.1` named twice links once. + assert_eq!(from, ["row", "row.1", "row.2", "tax"]); + assert!(got.iter().all(|l| l.to == "total" && l.tone == Tone::Ok)); + } + + #[test] + fn a_link_is_late_or_in_a_loop_as_its_reader_s_issues_say() { + let calculated = [ + field("total", &["sub", "shipping"], vec![CalculationIssue::RunsTooEarly { field: "sub".into() }]), + field("sub", &["qty"], vec![]), + field("x", &["y"], vec![CalculationIssue::Cycle { fields: vec!["y".into()] }]), + field("y", &["x"], vec![CalculationIssue::Cycle { fields: vec!["x".into()] }]), + ]; + let got = links(&calculated, vec!["qty", "shipping", "sub", "total", "x", "y"]); + assert_eq!( + got, + [ + link("sub", "total", Tone::Late), + link("shipping", "total", Tone::Ok), + link("qty", "sub", Tone::Ok), + link("y", "x", Tone::Loop), + link("x", "y", Tone::Loop), + ] + ); + } + + #[test] + fn links_stop_at_the_cap() { + let names: Vec = (0..MAX_LINKS + 10).map(|k| format!("g.{k}")).collect(); + let calculated = [field("sum", &["g"], vec![])]; + assert_eq!(links(&calculated, names.iter().map(String::as_str).collect()).len(), MAX_LINKS); + } + + #[test] + 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"); + } + + #[test] + fn loops_group_the_fields_that_read_each_other() { + let calculated = [ + field("a", &["b"], vec![CalculationIssue::Cycle { fields: vec!["b".into()] }]), + field("x", &["x"], vec![CalculationIssue::Cycle { fields: vec!["x".into()] }]), + field("ok", &[], vec![]), + field("c", &["a"], vec![CalculationIssue::Cycle { fields: vec!["a".into()] }]), + field("b", &["c"], vec![CalculationIssue::Cycle { fields: vec!["c".into()] }]), + ]; + assert_eq!(loops(&calculated), vec![vec!["a", "c", "b"], vec!["x"]]); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 62b29fea80..3d78f952d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -519,6 +519,8 @@ pub struct DocView { pub forms: crate::forms_ui::FormView, /// Prepare a form: the selected field and the gesture in progress. pub prepare: crate::prepare::PrepareView, + /// Prepare a form ▸ Calculation order. + pub(crate) calc_order: crate::calc_order_ui::CalcOrderView, /// Edit a PDF: the selected added item, the text being typed. pub content: crate::content_ui::ContentView, /// Edit a PDF ▸ Link tool state. @@ -799,6 +801,7 @@ impl DocView { measure: Default::default(), forms: Default::default(), prepare: Default::default(), + calc_order: Default::default(), content: Default::default(), links: Default::default(), redact_drag: None, @@ -2403,6 +2406,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = app.is_preparing(); + // Prepare a form ▸ Calculation order: the order's steps and links on the page. + let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; // Edit a PDF: added text and images can be selected, moved and edited. let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); @@ -2767,6 +2772,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut copy_selected = false; let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); + let generation = doc.edit_generation(); let can_fill = doc.allows_form_filling(); let can_crop = doc.allows_assembly(); let mut open_boxes = false; @@ -2892,6 +2898,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Set again below when the right-click lands on a link. view.links.context = None; } + let mut calc_hover = None; for &i in &visible_pages { badge_hover = None; let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); @@ -3460,6 +3467,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if preparing { crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + 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(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) { + calc_hover = Some(name); + } + } } else { crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); } @@ -3581,6 +3594,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { hover_text = badge_hover.take(); } } + if calc_order { + view.calc_order.set_hover_page(calc_hover); + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); + } // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b769c0c00c..49a0e20b77 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -313,6 +313,15 @@ impl PdfCraftApp { } } "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), + "form.calculation_order" => { + self.left = crate::LeftPanel::CalculationOrder; + // The page edits the fields, so Preview ends; the page grid closes, as for a tool. + self.form_preview = false; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.reveal_tools(); + } "comment.flatten" => { self.apply_edit(Edit::Flatten { comments: true, fields: false }); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 894e127be4..f6b3d093d5 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -519,6 +519,36 @@ Could not save: {e} تعذّر الحفظ: {e} Earlier in tab order أبكر في ترتيب التنقل Later in tab order أبعد في ترتيب التنقل + Calculation order ترتيب الحساب + Fix order إصلاح الترتيب + Move only what must move, so every field calculates after the fields it reads. ينقل فقط ما يجب نقله، كي يُحسب كل حقل بعد الحقول التي يقرؤها. + Earlier in calculation order أبكر في ترتيب الحساب + Later in calculation order أبعد في ترتيب الحساب + Too early مبكر جدًا + Reads {name}, which calculates later, so it shows an old value. يقرأ {name} الذي يُحسب لاحقًا، لذا يعرض قيمة قديمة. + Loop حلقة + 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 الأخرى. + 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} + {field} reads no other field {field} لا يقرأ أي حقل آخر + {field} reads {fields} {field} يقرأ {fields} + No field reads it لا يقرؤه أي حقل + The order saved in the PDF needs tidying يحتاج الترتيب المحفوظ في PDF إلى تنظيم + Moves only what must move ينقل فقط ما يجب نقله + Every field calculates after the fields it reads. يُحسب كل حقل بعد الحقول التي يقرؤها. + Every field outside a loop calculates after the fields it reads. يُحسب كل حقل خارج حلقة بعد الحقول التي يقرؤها. + {field} reads its own value {field} يقرأ قيمته نفسها + {fields} read each other {fields} يقرأ بعضها بعضًا + No order can fix a loop. Change one of the formulas. لا يمكن لأي ترتيب إصلاح حلقة. غيّر إحدى الصيغ. + Edit {field}'s formula تحرير صيغة {field} + Order · top calculates first الترتيب · الأعلى يُحسب أولًا + Script برنامج نصي + Links on page الروابط على الصفحة + Show on the page which fields each field reads إظهار الحقول التي يقرؤها كل حقل على الصفحة + Drag a field to move it, or select it and press {up} or {down}. اسحب حقلًا لنقله، أو حدده واضغط {up} أو {down}. From: من: To: إلى: High Resolution دقة عالية @@ -2364,6 +2394,12 @@ animation effects Off معطّلة no output folder was chosen لم يتم اختيار مجلد للإخراج @plural Downloaded {n} PDF|Downloaded {n} PDFs تم تنزيل {n} من ملفات PDF|تم تنزيل ملف PDF واحد ({n})|تم تنزيل ملفي PDF ({n})|تم تنزيل {n} ملفات PDF|تم تنزيل {n} ملف PDF|تم تنزيل {n} من ملفات PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. تم تحديث {n} من العمليات الحسابية لتستخدم الاسم الجديد.|عملية حسابية واحدة ({n}) تستخدم الآن الاسم الجديد.|عمليتان حسابيتان ({n}) تستخدمان الآن الاسم الجديد.|{n} عمليات حسابية تستخدم الآن الاسم الجديد.|{n} عملية حسابية تستخدم الآن الاسم الجديد.|{n} من العمليات الحسابية تستخدم الآن الاسم الجديد. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. يحتوي ترتيب الحساب على {n} من الإدخالات التي لا تحسب شيئًا. «إصلاح الترتيب» يزيلها.|يحتوي ترتيب الحساب على إدخال واحد ({n}) لا يحسب شيئًا. «إصلاح الترتيب» يزيله.|يحتوي ترتيب الحساب على إدخالين ({n}) لا يحسبان شيئًا. «إصلاح الترتيب» يزيلهما.|يحتوي ترتيب الحساب على {n} إدخالات لا تحسب شيئًا. «إصلاح الترتيب» يزيلها.|يحتوي ترتيب الحساب على {n} إدخالًا لا يحسب شيئًا. «إصلاح الترتيب» يزيلها.|يحتوي ترتيب الحساب على {n} من الإدخالات التي لا تحسب شيئًا. «إصلاح الترتيب» يزيلها. +@plural {fields} reads it|{fields} read it {fields} تقرؤه|{fields} يقرؤه|{fields} يقرآنه|{fields} تقرؤه|{fields} تقرؤه|{fields} تقرؤه +@plural {n} field shows an old value|{n} fields show old values {n} من الحقول تعرض قيمًا قديمة|حقل واحد ({n}) يعرض قيمة قديمة|حقلان ({n}) يعرضان قيمًا قديمة|{n} حقول تعرض قيمًا قديمة|{n} حقلًا يعرض قيمًا قديمة|{n} من الحقول تعرض قيمًا قديمة +@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 الأخرى. 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 e00febe68c..90e152c27b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -191,6 +191,36 @@ Could not save: {e} Неуспешно записване: {e} Earlier in tab order По-напред в реда на табулация Later in tab order По-назад в реда на табулация + Calculation order Ред на изчисляване + Fix order Поправяне на реда + Move only what must move, so every field calculates after the fields it reads. Премества само това, което трябва, така че всяко поле да се изчислява след полетата, които чете. + Earlier in calculation order По-напред в реда на изчисляване + Later in calculation order По-назад в реда на изчисляване + Too early Твърде рано + Reads {name}, which calculates later, so it shows an old value. Чете {name}, което се изчислява по-късно, затова показва стара стойност. + Loop Цикъл + 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 може да не го изчисляват. + 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} + {field} reads no other field {field} не чете друго поле + {field} reads {fields} {field} чете {fields} + No field reads it Никое поле не го чете + The order saved in the PDF needs tidying Редът, запазен в PDF файла, има нужда от подреждане + Moves only what must move Премества само каквото трябва + Every field calculates after the fields it reads. Всяко поле се изчислява след полетата, които чете. + Every field outside a loop calculates after the fields it reads. Всяко поле извън цикъл се изчислява след полетата, които чете. + {field} reads its own value {field} чете собствената си стойност + {fields} read each other {fields} се четат взаимно + No order can fix a loop. Change one of the formulas. Никой ред не може да поправи цикъл. Променете някоя от формулите. + Edit {field}'s formula Редактиране на формулата на {field} + Order · top calculates first Ред · горното се изчислява първо + Script Скрипт + Links on page Връзки на страницата + Show on the page which fields each field reads Показване на страницата кои полета чете всяко поле + Drag a field to move it, or select it and press {up} or {down}. Плъзнете поле, за да го преместите, или го изберете и натиснете {up} или {down}. From: От: To: До: High Resolution Висока резолюция @@ -2306,6 +2336,12 @@ animation effects Off Изключени no output folder was chosen не е избрана папка за изхода @plural Downloaded {n} PDF|Downloaded {n} PDFs Изтеглен е {n} PDF|Изтеглени са {n} PDF файла @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} изчисление вече използва новото име.|{n} изчисления вече използват новото име. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Редът на изчисляване има {n} запис, който не изчислява нищо. „Поправяне на реда“ го премахва.|Редът на изчисляване има {n} записа, които не изчисляват нищо. „Поправяне на реда“ ги премахва. +@plural {fields} reads it|{fields} read it {fields} го чете|{fields} го четат +@plural {n} field shows an old value|{n} fields show old values {n} поле показва стара стойност|{n} полета показват стари стойности +@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 може да не ги изчислят. 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/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 292c14a614..1366cb2546 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -599,6 +599,36 @@ key Space Mezerník Could not save: {e} Nelze uložit: {e} Earlier in tab order Dříve v pořadí tabulátoru Later in tab order Později v pořadí tabulátoru + Calculation order Pořadí výpočtů + Fix order Opravit pořadí + Move only what must move, so every field calculates after the fields it reads. Přesune jen to, co musí, aby se každé pole počítalo až po polích, která čte. + Earlier in calculation order Dříve v pořadí výpočtů + Later in calculation order Později v pořadí výpočtů + Too early Příliš brzy + Reads {name}, which calculates later, so it shows an old value. Čte {name}, které se počítá později, takže ukazuje starou hodnotu. + Loop Smyčka + 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í. + 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} + {field} reads no other field {field} nečte žádné jiné pole + {field} reads {fields} {field} čte {fields} + No field reads it Žádné pole ho nečte + The order saved in the PDF needs tidying Pořadí uložené v PDF je potřeba uklidit + Moves only what must move Přesune jen to, co musí + Every field calculates after the fields it reads. Každé pole se počítá až po polích, která čte. + Every field outside a loop calculates after the fields it reads. Každé pole mimo smyčku se počítá až po polích, která čte. + {field} reads its own value {field} čte svou vlastní hodnotu + {fields} read each other {fields} čtou jedno druhé + No order can fix a loop. Change one of the formulas. Smyčku žádné pořadí neopraví. Změňte jeden ze vzorců. + Edit {field}'s formula Upravit vzorec pole {field} + Order · top calculates first Pořadí · horní se počítá první + Script Skript + Links on page Vazby na stránce + Show on the page which fields each field reads Ukázat na stránce, která pole čte každé pole + Drag a field to move it, or select it and press {up} or {down}. Pole přesunete přetažením, nebo ho vyberte a stiskněte {up} nebo {down}. From: Od: To: Do: High Resolution Vysoké rozlišení @@ -2417,6 +2447,12 @@ animation effects Off Vypnuto no output folder was chosen nebyla vybrána výstupní složka @plural Downloaded {n} PDF|Downloaded {n} PDFs Stažen {n} soubor PDF|Staženy {n} soubory PDF|Staženo {n} souborů PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} výpočet nyní používá nový název.|{n} výpočty nyní používají nový název.|{n} výpočtů nyní používá nový název. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Pořadí výpočtů má {n} položku, která nic nepočítá. „Opravit pořadí“ ji odstraní.|Pořadí výpočtů má {n} položky, které nic nepočítají. „Opravit pořadí“ je odstraní.|Pořadí výpočtů má {n} položek, které nic nepočítají. „Opravit pořadí“ je odstraní. +@plural {fields} reads it|{fields} read it {fields} ho čte|{fields} ho čtou|{fields} ho čtou +@plural {n} field shows an old value|{n} fields show old values {n} pole ukazuje starou hodnotu|{n} pole ukazují staré hodnoty|{n} polí ukazuje staré hodnoty +@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. 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 2348e7df2b..ac136cd91a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -492,6 +492,36 @@ Could not save: {e} Speichern fehlgeschlagen: {e} Earlier in tab order Früher in der Tab-Reihenfolge Later in tab order Später in der Tab-Reihenfolge + Calculation order Berechnungsreihenfolge + Fix order Reihenfolge korrigieren + Move only what must move, so every field calculates after the fields it reads. Nur verschieben, was verschoben werden muss, damit jedes Feld nach den Feldern berechnet wird, die es liest. + Earlier in calculation order Früher in der Berechnungsreihenfolge + Later in calculation order Später in der Berechnungsreihenfolge + Too early Zu früh + Reads {name}, which calculates later, so it shows an old value. Liest {name}, das später berechnet wird, und zeigt deshalb einen alten Wert. + Loop Schleife + 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. + 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} + {field} reads no other field {field} liest kein anderes Feld + {field} reads {fields} {field} liest {fields} + No field reads it Kein Feld liest es + The order saved in the PDF needs tidying Die im PDF gespeicherte Reihenfolge muss bereinigt werden + Moves only what must move Verschiebt nur, was verschoben werden muss + Every field calculates after the fields it reads. Jedes Feld wird nach den Feldern berechnet, die es liest. + Every field outside a loop calculates after the fields it reads. Jedes Feld außerhalb einer Schleife wird nach den Feldern berechnet, die es liest. + {field} reads its own value {field} liest seinen eigenen Wert + {fields} read each other {fields} lesen sich gegenseitig + No order can fix a loop. Change one of the formulas. Keine Reihenfolge kann eine Schleife beheben. Ändere eine der Formeln. + Edit {field}'s formula Formel von {field} bearbeiten + Order · top calculates first Reihenfolge · oben wird zuerst berechnet + Script Skript + Links on page Verbindungen auf der Seite + Show on the page which fields each field reads Auf der Seite zeigen, welche Felder jedes Feld liest + Drag a field to move it, or select it and press {up} or {down}. Ziehe ein Feld, um es zu verschieben, oder wähle es aus und drücke {up} oder {down}. From: Von: To: Bis: High Resolution Hohe Auflösung @@ -2344,6 +2374,12 @@ animation effects Off Aus no output folder was chosen kein Ausgabeordner gewählt @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF heruntergeladen|{n} PDFs heruntergeladen @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} Berechnung verwendet jetzt den neuen Namen.|{n} Berechnungen verwenden jetzt den neuen Namen. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Die Berechnungsreihenfolge hat {n} Eintrag, der nichts berechnet. „Reihenfolge korrigieren“ entfernt ihn.|Die Berechnungsreihenfolge hat {n} Einträge, die nichts berechnen. „Reihenfolge korrigieren“ entfernt sie. +@plural {fields} reads it|{fields} read it {fields} liest es|{fields} lesen es +@plural {n} field shows an old value|{n} fields show old values {n} Feld zeigt einen alten Wert|{n} Felder zeigen alte Werte +@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. 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 c12ba3bea7..133e8ca8e6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1830,6 +1830,36 @@ Could not save: {e} Δεν ήταν δυνατή η αποθήκευση: {e} Earlier in tab order Νωρίτερα στη σειρά μετακίνησης με Tab Later in tab order Αργότερα στη σειρά μετακίνησης με Tab + Calculation order Σειρά υπολογισμού + Fix order Διόρθωση σειράς + Move only what must move, so every field calculates after the fields it reads. Μετακινεί μόνο ό,τι πρέπει, ώστε κάθε πεδίο να υπολογίζεται μετά τα πεδία που διαβάζει. + Earlier in calculation order Νωρίτερα στη σειρά υπολογισμού + Later in calculation order Αργότερα στη σειρά υπολογισμού + Too early Πολύ νωρίς + Reads {name}, which calculates later, so it shows an old value. Διαβάζει το {name}, που υπολογίζεται αργότερα, οπότε δείχνει παλιά τιμή. + Loop Βρόχος + 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 ίσως δεν το υπολογίζουν. + 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} + {field} reads no other field {field} δεν διαβάζει άλλο πεδίο + {field} reads {fields} {field} διαβάζει {fields} + No field reads it Κανένα πεδίο δεν το διαβάζει + The order saved in the PDF needs tidying Η σειρά που είναι αποθηκευμένη στο PDF χρειάζεται τακτοποίηση + Moves only what must move Μετακινεί μόνο ό,τι πρέπει + Every field calculates after the fields it reads. Κάθε πεδίο υπολογίζεται μετά από τα πεδία που διαβάζει. + Every field outside a loop calculates after the fields it reads. Κάθε πεδίο εκτός βρόχου υπολογίζεται μετά από τα πεδία που διαβάζει. + {field} reads its own value {field} διαβάζει τη δική του τιμή + {fields} read each other {fields} διαβάζουν το ένα το άλλο + No order can fix a loop. Change one of the formulas. Καμία σειρά δεν διορθώνει έναν βρόχο. Αλλάξτε έναν από τους τύπους. + Edit {field}'s formula Επεξεργασία του τύπου του {field} + Order · top calculates first Σειρά · το πάνω υπολογίζεται πρώτο + Script Σενάριο + Links on page Συνδέσεις στη σελίδα + Show on the page which fields each field reads Εμφάνιση στη σελίδα των πεδίων που διαβάζει κάθε πεδίο + Drag a field to move it, or select it and press {up} or {down}. Σύρετε ένα πεδίο για να το μετακινήσετε ή επιλέξτε το και πατήστε {up} ή {down}. From: Από: To: Έως: High Resolution Υψηλή ανάλυση @@ -2340,6 +2370,12 @@ animation effects Off Ανενεργά no output folder was chosen δεν επιλέχθηκε φάκελος εξόδου @plural Downloaded {n} PDF|Downloaded {n} PDFs Λήφθηκε {n} PDF|Λήφθηκαν {n} PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} υπολογισμός χρησιμοποιεί πλέον το νέο όνομα.|{n} υπολογισμοί χρησιμοποιούν πλέον το νέο όνομα. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Η σειρά υπολογισμού έχει {n} καταχώριση που δεν υπολογίζει τίποτα. Η «Διόρθωση σειράς» την αφαιρεί.|Η σειρά υπολογισμού έχει {n} καταχωρίσεις που δεν υπολογίζουν τίποτα. Η «Διόρθωση σειράς» τις αφαιρεί. +@plural {fields} reads it|{fields} read it {fields} το διαβάζει|{fields} το διαβάζουν +@plural {n} field shows an old value|{n} fields show old values {n} πεδίο δείχνει παλιά τιμή|{n} πεδία δείχνουν παλιές τιμές +@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} 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 3aaea3a825..7c7b7c8d71 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -515,6 +515,36 @@ Could not save: {e} No se pudo guardar: {e} Earlier in tab order Antes en el orden de tabulación Later in tab order Después en el orden de tabulación + Calculation order Orden de cálculo + Fix order Corregir orden + Move only what must move, so every field calculates after the fields it reads. Mueve solo lo que debe moverse, para que cada campo se calcule después de los campos que lee. + Earlier in calculation order Antes en el orden de cálculo + Later in calculation order Después en el orden de cálculo + Too early Demasiado pronto + Reads {name}, which calculates later, so it shows an old value. Lee {name}, que se calcula después, así que muestra un valor antiguo. + Loop Bucle + 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. + 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} + {field} reads no other field {field} no lee ningún otro campo + {field} reads {fields} {field} lee {fields} + No field reads it Ningún campo lo lee + The order saved in the PDF needs tidying El orden guardado en el PDF necesita una limpieza + Moves only what must move Mueve solo lo que debe moverse + Every field calculates after the fields it reads. Cada campo se calcula después de los campos que lee. + Every field outside a loop calculates after the fields it reads. Cada campo fuera de un bucle se calcula después de los campos que lee. + {field} reads its own value {field} lee su propio valor + {fields} read each other {fields} se leen entre sí + No order can fix a loop. Change one of the formulas. Ningún orden puede corregir un bucle. Cambia una de las fórmulas. + Edit {field}'s formula Editar la fórmula de {field} + Order · top calculates first Orden · lo de arriba se calcula primero + Script Script + Links on page Vínculos en la página + Show on the page which fields each field reads Mostrar en la página qué campos lee cada campo + Drag a field to move it, or select it and press {up} or {down}. Arrastra un campo para moverlo, o selecciónalo y pulsa {up} o {down}. From: Desde: To: Hasta: High Resolution Alta resolución @@ -2472,6 +2502,12 @@ animation effects Off Desactivados no output folder was chosen no se eligió ninguna carpeta de salida @plural Downloaded {n} PDF|Downloaded {n} PDFs Se descargó {n} PDF|Se descargaron {n} PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo usa ahora el nuevo nombre.|{n} cálculos usan ahora el nuevo nombre. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. El orden de cálculo tiene {n} entrada que no calcula nada. «Corregir orden» la elimina.|El orden de cálculo tiene {n} entradas que no calculan nada. «Corregir orden» las elimina. +@plural {fields} reads it|{fields} read it {fields} lo lee|{fields} lo leen +@plural {n} field shows an old value|{n} fields show old values {n} campo muestra un valor antiguo|{n} campos muestran valores antiguos +@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. 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/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index aaff44cb51..aa83f81970 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -492,6 +492,36 @@ Could not save: {e} Impossible d'enregistrer : {e} Earlier in tab order Plus tôt dans l'ordre de tabulation Later in tab order Plus tard dans l'ordre de tabulation + Calculation order Ordre de calcul + Fix order Corriger l'ordre + Move only what must move, so every field calculates after the fields it reads. Ne déplacer que ce qui doit l'être, pour que chaque champ soit calculé après les champs qu'il lit. + Earlier in calculation order Plus tôt dans l'ordre de calcul + Later in calculation order Plus tard dans l'ordre de calcul + Too early Trop tôt + Reads {name}, which calculates later, so it shows an old value. Lit {name}, qui est calculé plus tard : il affiche donc une ancienne valeur. + Loop Boucle + 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. + 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} + {field} reads no other field {field} ne lit aucun autre champ + {field} reads {fields} {field} lit {fields} + No field reads it Aucun champ ne le lit + The order saved in the PDF needs tidying L'ordre enregistré dans le PDF doit être nettoyé + Moves only what must move Déplace seulement ce qui doit l'être + Every field calculates after the fields it reads. Chaque champ est calculé après les champs qu'il lit. + Every field outside a loop calculates after the fields it reads. Chaque champ hors d'une boucle est calculé après les champs qu'il lit. + {field} reads its own value {field} lit sa propre valeur + {fields} read each other {fields} se lisent mutuellement + No order can fix a loop. Change one of the formulas. Aucun ordre ne peut corriger une boucle. Modifiez l'une des formules. + Edit {field}'s formula Modifier la formule de {field} + Order · top calculates first Ordre · le haut est calculé en premier + Script Script + Links on page Liens sur la page + Show on the page which fields each field reads Afficher sur la page les champs que lit chaque champ + Drag a field to move it, or select it and press {up} or {down}. Faites glisser un champ pour le déplacer, ou sélectionnez-le et appuyez sur {up} ou {down}. From: De : To: À : High Resolution Haute résolution @@ -2343,6 +2373,12 @@ animation effects Off Désactivés no output folder was chosen aucun dossier de sortie choisi @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF téléchargé|{n} PDF téléchargés @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcul utilise désormais le nouveau nom.|{n} calculs utilisent désormais le nouveau nom. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. L'ordre de calcul contient {n} entrée qui ne calcule rien. « Corriger l'ordre » la supprime.|L'ordre de calcul contient {n} entrées qui ne calculent rien. « Corriger l'ordre » les supprime. +@plural {fields} reads it|{fields} read it {fields} le lit|{fields} le lisent +@plural {n} field shows an old value|{n} fields show old values {n} champ affiche une ancienne valeur|{n} champs affichent d'anciennes valeurs +@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. 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/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index ff23633508..123be343f3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -490,6 +490,36 @@ Could not save: {e} A mentés nem sikerült: {e} Earlier in tab order Előbbre a tab-sorrendben Later in tab order Hátrébb a tab-sorrendben + Calculation order Számítási sorrend + Fix order Sorrend javítása + Move only what must move, so every field calculates after the fields it reads. Csak azt mozgatja, amit muszáj, hogy minden mező az általa olvasott mezők után számítódjon. + Earlier in calculation order Előbbre a számítási sorrendben + Later in calculation order Hátrébb a számítási sorrendben + Too early Túl korán + Reads {name}, which calculates later, so it shows an old value. Ezt olvassa: {name}, amely később számítódik, ezért régi értéket mutat. + Loop Hurok + 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. + 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} + {field} reads no other field {field} nem olvas más mezőt + {field} reads {fields} {field} olvassa: {fields} + No field reads it Egyik mező sem olvassa + The order saved in the PDF needs tidying A PDF-ben mentett sorrend rendbetételre szorul + Moves only what must move Csak azt mozgatja, amit muszáj + Every field calculates after the fields it reads. Minden mező az általa olvasott mezők után számítódik. + Every field outside a loop calculates after the fields it reads. A hurkokon kívül minden mező az általa olvasott mezők után számítódik. + {field} reads its own value {field} a saját értékét olvassa + {fields} read each other {fields} egymást olvassák + No order can fix a loop. Change one of the formulas. Egy hurkot semmilyen sorrend nem javít meg. Módosítsd az egyik képletet. + Edit {field}'s formula {field} képletének szerkesztése + Order · top calculates first Sorrend · a felső számítódik először + Script Szkript + Links on page Kapcsolatok az oldalon + Show on the page which fields each field reads Az oldalon mutatja, melyik mező mely mezőket olvassa + Drag a field to move it, or select it and press {up} or {down}. Húzz el egy mezőt az áthelyezéshez, vagy jelöld ki, és nyomd meg ezt: {up} vagy {down}. From: Ettől: To: Eddig: High Resolution Nagy felbontás @@ -2342,6 +2372,12 @@ animation effects Off Ki no output folder was chosen nincs kiválasztva kimeneti mappa @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF letöltve|{n} PDF letöltve @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} számítás mostantól az új nevet használja.|{n} számítás mostantól az új nevet használja. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. A számítási sorrendben {n} bejegyzés semmit nem számít ki. A „Sorrend javítása” eltávolítja.|A számítási sorrendben {n} bejegyzés semmit nem számít ki. A „Sorrend javítása” eltávolítja őket. +@plural {fields} reads it|{fields} read it {fields} olvassa|{fields} olvassák +@plural {n} field shows an old value|{n} fields show old values {n} mező régi értéket mutat|{n} mező régi értéket mutat +@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. 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/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cf65e3167a..71715dc5d7 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -516,6 +516,36 @@ Could not save: {e} Impossibile salvare: {e} Earlier in tab order Prima nell'ordine di tabulazione Later in tab order Dopo nell'ordine di tabulazione + Calculation order Ordine di calcolo + Fix order Correggi ordine + Move only what must move, so every field calculates after the fields it reads. Sposta solo ciò che deve spostarsi, così ogni campo viene calcolato dopo i campi che legge. + Earlier in calculation order Prima nell'ordine di calcolo + Later in calculation order Dopo nell'ordine di calcolo + Too early Troppo presto + Reads {name}, which calculates later, so it shows an old value. Legge {name}, che viene calcolato dopo, quindi mostra un valore vecchio. + Loop Ciclo + 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. + 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} + {field} reads no other field {field} non legge nessun altro campo + {field} reads {fields} {field} legge {fields} + No field reads it Nessun campo lo legge + The order saved in the PDF needs tidying L'ordine salvato nel PDF va riordinato + Moves only what must move Sposta solo ciò che va spostato + Every field calculates after the fields it reads. Ogni campo viene calcolato dopo i campi che legge. + Every field outside a loop calculates after the fields it reads. Ogni campo fuori da un ciclo viene calcolato dopo i campi che legge. + {field} reads its own value {field} legge il proprio valore + {fields} read each other {fields} si leggono a vicenda + No order can fix a loop. Change one of the formulas. Nessun ordine può correggere un ciclo. Modifica una delle formule. + Edit {field}'s formula Modifica la formula di {field} + Order · top calculates first Ordine · in alto si calcola per primo + Script Script + Links on page Collegamenti sulla pagina + Show on the page which fields each field reads Mostra sulla pagina quali campi legge ogni campo + Drag a field to move it, or select it and press {up} or {down}. Trascina un campo per spostarlo, oppure selezionalo e premi {up} o {down}. From: Da: To: A: High Resolution Alta risoluzione @@ -2352,6 +2382,12 @@ animation effects Off Disattivati no output folder was chosen nessuna cartella di output scelta @plural Downloaded {n} PDF|Downloaded {n} PDFs Scaricato {n} PDF|Scaricati {n} PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcolo ora usa il nuovo nome.|{n} calcoli ora usano il nuovo nome. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. L'ordine di calcolo ha {n} voce che non calcola nulla. «Correggi ordine» la rimuove.|L'ordine di calcolo ha {n} voci che non calcolano nulla. «Correggi ordine» le rimuove. +@plural {fields} reads it|{fields} read it {fields} lo legge|{fields} lo leggono +@plural {n} field shows an old value|{n} fields show old values {n} campo mostra un valore vecchio|{n} campi mostrano valori vecchi +@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. 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 2dd0482591..f73e753def 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -519,6 +519,36 @@ Could not save: {e} 保存できませんでした: {e} Earlier in tab order タブ順序を前へ Later in tab order タブ順序を後ろへ + Calculation order 計算順序 + Fix order 順序を修正 + Move only what must move, so every field calculates after the fields it reads. 各フィールドが読み取るフィールドの後に計算されるよう、必要なものだけを移動します。 + Earlier in calculation order 計算順序を前へ + Later in calculation order 計算順序を後ろへ + Too early 早すぎる + Reads {name}, which calculates later, so it shows an old value. 後で計算される {name} を読み取るため、古い値を表示します。 + Loop 循環 + 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 ビューアーでは計算されない可能性があります。 + 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} + {field} reads no other field {field} は他のフィールドを読み取りません + {field} reads {fields} {field} は {fields} を読み取ります + No field reads it 読み取るフィールドはありません + The order saved in the PDF needs tidying PDF に保存された順序の整理が必要です + Moves only what must move 必要な分だけ移動します + Every field calculates after the fields it reads. すべてのフィールドが、読み取るフィールドの後に計算されます。 + Every field outside a loop calculates after the fields it reads. 循環の外にあるすべてのフィールドが、読み取るフィールドの後に計算されます。 + {field} reads its own value {field} は自分の値を読み取ります + {fields} read each other {fields} は互いを読み取ります + No order can fix a loop. Change one of the formulas. 循環はどの順序でも直せません。いずれかの数式を変更してください。 + Edit {field}'s formula {field} の数式を編集 + Order · top calculates first 順序 · 上から計算 + Script スクリプト + Links on page ページ上のリンク + Show on the page which fields each field reads 各フィールドが読み取るフィールドをページ上に表示します + Drag a field to move it, or select it and press {up} or {down}. フィールドをドラッグして移動するか、選択して {up} または {down} を押します。 From: 開始: To: 終了: High Resolution 高解像度 @@ -2343,6 +2373,12 @@ animation effects Off オフ no output folder was chosen 出力フォルダーが選択されていません @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} 個の PDF をダウンロードしました @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 件の計算式を新しい名前に更新しました。 +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. 計算順序に、何も計算しない項目が {n} 件あります。「順序を修正」で削除されます。 +@plural {fields} reads it|{fields} read it {fields} が読み取ります +@plural {n} field shows an old value|{n} fields show old values {n} 個のフィールドが古い値を表示しています +@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 ビューアーでは計算されない場合があります。 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 e72f8c2611..7f113d4bfc 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -479,6 +479,36 @@ Could not save: {e} Сақтау мүмкін болмады: {e} Earlier in tab order Табуляция реті бойынша алдыңғы Later in tab order Табуляция реті бойынша кейінгі + Calculation order Есептеу реті + Fix order Ретті түзету + Move only what must move, so every field calculates after the fields it reads. Әр өріс өзі оқитын өрістерден кейін есептелуі үшін тек жылжуы қажет нәрсені ғана жылжытады. + Earlier in calculation order Есептеу реті бойынша алдыңғы + Later in calculation order Есептеу реті бойынша кейінгі + Too early Тым ерте + Reads {name}, which calculates later, so it shows an old value. {name} өрісін оқиды, ол кейінірек есептеледі, сондықтан ескі мәнді көрсетеді. + Loop Цикл + 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 қарау құралдары оны есептемеуі мүмкін. + 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} + {field} reads no other field {field} басқа өрісті оқымайды + {field} reads {fields} {field} мыналарды оқиды: {fields} + No field reads it Оны ешбір өріс оқымайды + The order saved in the PDF needs tidying PDF-те сақталған ретті ретке келтіру керек + Moves only what must move Тек жылжуы тиістіні ғана жылжытады + Every field calculates after the fields it reads. Әр өріс өзі оқитын өрістерден кейін есептеледі. + Every field outside a loop calculates after the fields it reads. Циклден тыс әр өріс өзі оқитын өрістерден кейін есептеледі. + {field} reads its own value {field} өз мәнін оқиды + {fields} read each other {fields} бірін-бірі оқиды + No order can fix a loop. Change one of the formulas. Ешбір рет циклді түзете алмайды. Формулалардың бірін өзгертіңіз. + Edit {field}'s formula {field} формуласын өңдеу + Order · top calculates first Рет · жоғарғысы бірінші есептеледі + Script Скрипт + Links on page Беттегі байланыстар + Show on the page which fields each field reads Әр өріс қай өрістерді оқитынын бетте көрсету + Drag a field to move it, or select it and press {up} or {down}. Өрісті жылжыту үшін сүйреңіз немесе оны таңдап, {up} не {down} пернесін басыңыз. From: Бастап: To: Дейін: High Resolution Жоғары ажыратымдылық @@ -2330,6 +2360,12 @@ animation effects Off Өшірулі no output folder was chosen шығыс қалтасы таңдалмады @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF жүктелді|{n} PDF жүктелді @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} есептеу енді жаңа атауды пайдаланады.|{n} есептеу енді жаңа атауды пайдаланады. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Есептеу ретінде ештеңе есептемейтін {n} жазба бар. «Ретті түзету» оны жояды.|Есептеу ретінде ештеңе есептемейтін {n} жазба бар. «Ретті түзету» оларды жояды. +@plural {fields} reads it|{fields} read it Оны {fields} оқиды|Оны {fields} оқиды +@plural {n} field shows an old value|{n} fields show old values {n} өріс ескі мәнді көрсетеді|{n} өріс ескі мәндерді көрсетеді +@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} 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/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 981cf685c3..6448c6949e 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -685,6 +685,36 @@ Larger pages Didesni puslapiai Later Vėliau Later in tab order Vėlesnė skirtukų tvarka + Calculation order Skaičiavimo tvarka + Fix order Pataisyti tvarką + Move only what must move, so every field calculates after the fields it reads. Perkelia tik tai, ką būtina, kad kiekvienas laukas būtų skaičiuojamas po laukų, kuriuos jis skaito. + Earlier in calculation order Anksčiau skaičiavimo tvarkoje + Later in calculation order Vėliau skaičiavimo tvarkoje + Too early Per anksti + Reads {name}, which calculates later, so it shows an old value. Skaito {name}, kuris skaičiuojamas vėliau, todėl rodo seną reikšmę. + Loop Ciklas + 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. + 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} + {field} reads no other field {field} neskaito jokio kito lauko + {field} reads {fields} {field} skaito {fields} + No field reads it Jo neskaito joks laukas + The order saved in the PDF needs tidying PDF faile įrašytą tvarką reikia sutvarkyti + Moves only what must move Perkelia tik tai, ką būtina + Every field calculates after the fields it reads. Kiekvienas laukas apskaičiuojamas po laukų, kuriuos skaito. + Every field outside a loop calculates after the fields it reads. Kiekvienas laukas už ciklo ribų apskaičiuojamas po laukų, kuriuos skaito. + {field} reads its own value {field} skaito savo paties reikšmę + {fields} read each other {fields} skaito vienas kitą + No order can fix a loop. Change one of the formulas. Jokia tvarka neištaisys ciklo. Pakeiskite vieną iš formulių. + Edit {field}'s formula Redaguoti lauko {field} formulę + Order · top calculates first Tvarka · viršutinis apskaičiuojamas pirmas + Script Scenarijus + Links on page Ryšiai puslapyje + Show on the page which fields each field reads Rodyti puslapyje, kuriuos laukus skaito kiekvienas laukas + Drag a field to move it, or select it and press {up} or {down}. Vilkite lauką, kad jį perkeltumėte, arba pažymėkite jį ir paspauskite {up} arba {down}. Layers Sluoksniai Layers Panel and Page Sluoksnių skydelis ir puslapis Layout and Magnification Išdėstymas ir mastelis @@ -2600,6 +2630,12 @@ certificate SHA-256 digest SHA-256 santrauka @plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų @plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} skaičiavimas dabar naudoja naują pavadinimą.|{n} skaičiavimai dabar naudoja naują pavadinimą.|{n} skaičiavimų dabar naudoja naują pavadinimą. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Skaičiavimo tvarkoje yra {n} įrašas, kuris nieko neskaičiuoja. „Pataisyti tvarką“ jį pašalina.|Skaičiavimo tvarkoje yra {n} įrašai, kurie nieko neskaičiuoja. „Pataisyti tvarką“ juos pašalina.|Skaičiavimo tvarkoje yra {n} įrašų, kurie nieko neskaičiuoja. „Pataisyti tvarką“ juos pašalina. +@plural {fields} reads it|{fields} read it {fields} jį skaito|{fields} jį skaito|{fields} jį skaito +@plural {n} field shows an old value|{n} fields show old values {n} laukas rodo seną reikšmę|{n} laukai rodo senas reikšmes|{n} laukų rodo senas reikšmes +@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. 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 df1cd556e3..578b512bb7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -514,6 +514,36 @@ Could not save: {e} Nie można zapisać: {e} Earlier in tab order Wcześniej w kolejności tabulacji Later in tab order Później w kolejności tabulacji + Calculation order Kolejność obliczeń + Fix order Popraw kolejność + Move only what must move, so every field calculates after the fields it reads. Przenosi tylko to, co musi, aby każde pole było obliczane po polach, które odczytuje. + Earlier in calculation order Wcześniej w kolejności obliczeń + Later in calculation order Później w kolejności obliczeń + Too early Za wcześnie + Reads {name}, which calculates later, so it shows an old value. Odczytuje {name}, które jest obliczane później, więc pokazuje starą wartość. + Loop Pętla + 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ć. + 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} + {field} reads no other field {field} nie odczytuje żadnego innego pola + {field} reads {fields} {field} odczytuje {fields} + No field reads it Żadne pole go nie odczytuje + The order saved in the PDF needs tidying Kolejność zapisana w pliku PDF wymaga uporządkowania + Moves only what must move Przesuwa tylko to, co trzeba + Every field calculates after the fields it reads. Każde pole jest obliczane po polach, które odczytuje. + Every field outside a loop calculates after the fields it reads. Każde pole poza pętlą jest obliczane po polach, które odczytuje. + {field} reads its own value {field} odczytuje własną wartość + {fields} read each other {fields} odczytują się nawzajem + No order can fix a loop. Change one of the formulas. Żadna kolejność nie naprawi pętli. Zmień jedną z formuł. + Edit {field}'s formula Edytuj formułę pola {field} + Order · top calculates first Kolejność · najpierw obliczane jest górne + Script Skrypt + Links on page Powiązania na stronie + Show on the page which fields each field reads Pokaż na stronie, które pola odczytuje każde pole + Drag a field to move it, or select it and press {up} or {down}. Przeciągnij pole, aby je przenieść, albo zaznacz je i naciśnij {up} lub {down}. From: Od: To: Do: High Resolution Wysoka rozdzielczość @@ -2311,6 +2341,12 @@ animation effects Off Wyłączone no output folder was chosen nie wybrano folderu wyjściowego @plural Downloaded {n} PDF|Downloaded {n} PDFs Pobrano {n} plik PDF|Pobrano {n} pliki PDF|Pobrano {n} plików PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} obliczenie używa teraz nowej nazwy.|{n} obliczenia używają teraz nowej nazwy.|{n} obliczeń używa teraz nowej nazwy. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Kolejność obliczeń ma {n} wpis, który niczego nie oblicza. „Popraw kolejność” go usuwa.|Kolejność obliczeń ma {n} wpisy, które niczego nie obliczają. „Popraw kolejność” je usuwa.|Kolejność obliczeń ma {n} wpisów, które niczego nie obliczają. „Popraw kolejność” je usuwa. +@plural {fields} reads it|{fields} read it {fields} go odczytuje|{fields} go odczytują|{fields} go odczytują +@plural {n} field shows an old value|{n} fields show old values {n} pole pokazuje starą wartość|{n} pola pokazują stare wartości|{n} pól pokazuje stare wartości +@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ć. 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 c32f9a5118..ee12fe7ab2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -147,6 +147,36 @@ Could not save: {e} Não foi possível salvar: {e} Earlier in tab order Mais acima na ordem de tabulação Later in tab order Mais abaixo na ordem de tabulação + Calculation order Ordem de cálculo + Fix order Corrigir ordem + Move only what must move, so every field calculates after the fields it reads. Mover só o que precisa ser movido, para que cada campo seja calculado depois dos campos que ele lê. + Earlier in calculation order Mais acima na ordem de cálculo + Later in calculation order Mais abaixo na ordem de cálculo + Too early Cedo demais + Reads {name}, which calculates later, so it shows an old value. Lê {name}, que é calculado depois, então mostra um valor antigo. + Loop Ciclo + 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. + 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} + {field} reads no other field {field} não lê nenhum outro campo + {field} reads {fields} {field} lê {fields} + No field reads it Nenhum campo o lê + The order saved in the PDF needs tidying A ordem salva no PDF precisa de uma arrumação + Moves only what must move Move só o que precisa mudar + Every field calculates after the fields it reads. Cada campo é calculado depois dos campos que lê. + Every field outside a loop calculates after the fields it reads. Cada campo fora de um ciclo é calculado depois dos campos que lê. + {field} reads its own value {field} lê o próprio valor + {fields} read each other {fields} leem um ao outro + No order can fix a loop. Change one of the formulas. Nenhuma ordem corrige um ciclo. Altere uma das fórmulas. + Edit {field}'s formula Editar a fórmula de {field} + Order · top calculates first Ordem · o de cima é calculado primeiro + Script Script + Links on page Ligações na página + Show on the page which fields each field reads Mostrar na página quais campos cada campo lê + Drag a field to move it, or select it and press {up} or {down}. Arraste um campo para movê-lo, ou selecione-o e pressione {up} ou {down}. From: De: To: Até: High Resolution Alta resolução @@ -2357,6 +2387,12 @@ animation effects Off Desativados no output folder was chosen nenhuma pasta de saída foi escolhida @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF baixado|{n} PDFs baixados @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo agora usa o novo nome.|{n} cálculos agora usam o novo nome. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. A ordem de cálculo tem {n} entrada que não calcula nada. "Corrigir ordem" a remove.|A ordem de cálculo tem {n} entradas que não calculam nada. "Corrigir ordem" as remove. +@plural {fields} reads it|{fields} read it {fields} o lê|{fields} o leem +@plural {n} field shows an old value|{n} fields show old values {n} campo mostra um valor antigo|{n} campos mostram valores antigos +@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. 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/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3b5fbf628a..3baec45600 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -512,6 +512,36 @@ Could not save: {e} Не удалось сохранить: {e} Earlier in tab order Раньше в порядке табуляции Later in tab order Позже в порядке табуляции + Calculation order Порядок вычислений + Fix order Исправить порядок + Move only what must move, so every field calculates after the fields it reads. Перемещает только то, что необходимо, чтобы каждое поле вычислялось после полей, которые оно читает. + Earlier in calculation order Раньше в порядке вычислений + Later in calculation order Позже в порядке вычислений + Too early Слишком рано + Reads {name}, which calculates later, so it shows an old value. Читает {name}, которое вычисляется позже, поэтому показывает старое значение. + Loop Цикл + 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 могут его не вычислять. + 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} + {field} reads no other field {field} не читает других полей + {field} reads {fields} {field} читает {fields} + No field reads it Его не читает ни одно поле + The order saved in the PDF needs tidying Сохранённый в PDF порядок нуждается в исправлении + Moves only what must move Перемещает только то, что нужно + Every field calculates after the fields it reads. Каждое поле вычисляется после полей, которые оно читает. + Every field outside a loop calculates after the fields it reads. Каждое поле вне цикла вычисляется после полей, которые оно читает. + {field} reads its own value {field} читает собственное значение + {fields} read each other {fields} читают друг друга + No order can fix a loop. Change one of the formulas. Никакой порядок не исправит цикл. Измените одну из формул. + Edit {field}'s formula Изменить формулу {field} + Order · top calculates first Порядок · верхнее вычисляется первым + Script Скрипт + Links on page Связи на странице + Show on the page which fields each field reads Показывать на странице, какие поля читает каждое поле + Drag a field to move it, or select it and press {up} or {down}. Перетащите поле, чтобы переместить его, или выберите его и нажмите {up} или {down}. From: От: To: До: High Resolution Высокое разрешение @@ -2256,6 +2286,12 @@ animation effects Off Выкл no output folder was chosen не выбрана папка для вывода @plural Downloaded {n} PDF|Downloaded {n} PDFs Скачан {n} PDF-файл|Скачано {n} PDF-файла|Скачано {n} PDF-файлов @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} вычисление теперь использует новое имя.|{n} вычисления теперь используют новое имя.|{n} вычислений теперь используют новое имя. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. В порядке вычислений {n} запись, которая ничего не вычисляет. «Исправить порядок» удалит её.|В порядке вычислений {n} записи, которые ничего не вычисляют. «Исправить порядок» удалит их.|В порядке вычислений {n} записей, которые ничего не вычисляют. «Исправить порядок» удалит их. +@plural {fields} reads it|{fields} read it {fields} читает его|{fields} читают его|{fields} читают его +@plural {n} field shows an old value|{n} fields show old values {n} поле показывает старое значение|{n} поля показывают старые значения|{n} полей показывают старые значения +@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 могут их не вычислить. 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 cc92401df2..5ba9dc90c3 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1007,6 +1007,36 @@ Last commit Zadnji commit Later Prekliči Later in tab order Pozneje v vrstnem redu tabulatorja + Calculation order Vrstni red izračunov + Fix order Popravi vrstni red + Move only what must move, so every field calculates after the fields it reads. Premakne le tisto, kar se mora premakniti, da se vsako polje izračuna po poljih, ki jih bere. + Earlier in calculation order Prej v vrstnem redu izračunov + Later in calculation order Pozneje v vrstnem redu izračunov + Too early Prezgodaj + Reads {name}, which calculates later, so it shows an old value. Bere {name}, ki se izračuna pozneje, zato prikazuje staro vrednost. + Loop Zanka + 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. + 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} + {field} reads no other field {field} ne bere nobenega drugega polja + {field} reads {fields} {field} bere {fields} + No field reads it Nobeno polje ga ne bere + The order saved in the PDF needs tidying Vrstni red, shranjen v PDF-ju, je treba urediti + Moves only what must move Premakne le, kar je treba + Every field calculates after the fields it reads. Vsako polje se izračuna po poljih, ki jih bere. + Every field outside a loop calculates after the fields it reads. Vsako polje zunaj zanke se izračuna po poljih, ki jih bere. + {field} reads its own value {field} bere svojo lastno vrednost + {fields} read each other {fields} se berejo med seboj + No order can fix a loop. Change one of the formulas. Noben vrstni red ne popravi zanke. Spremenite eno od formul. + Edit {field}'s formula Uredi formulo polja {field} + Order · top calculates first Vrstni red · zgornje se izračuna prvo + Script Skript + Links on page Povezave na strani + Show on the page which fields each field reads Na strani pokaži, katera polja bere posamezno polje + Drag a field to move it, or select it and press {up} or {down}. Polje premaknete tako, da ga povlečete, ali pa ga izberite in pritisnite {up} ali {down}. Layers Plasti Layers Panel and Page Razdelek in stran s plastmi Layout and Magnification Postavitev in povečava @@ -2347,6 +2377,12 @@ animation effects Off Izklopljeni no output folder was chosen izhodna mapa ni bila izbrana @plural Downloaded {n} PDF|Downloaded {n} PDFs Prenesen {n} PDF|Prenesena {n} PDF-ja|Preneseni {n} PDF-ji|Prenesenih {n} PDF-jev @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} izračun zdaj uporablja novo ime.|{n} izračuna zdaj uporabljata novo ime.|{n} izračuni zdaj uporabljajo novo ime.|{n} izračunov zdaj uporablja novo ime. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Vrstni red izračunov ima {n} vnos, ki ne izračuna ničesar. »Popravi vrstni red« ga odstrani.|Vrstni red izračunov ima {n} vnosa, ki ne izračunata ničesar. »Popravi vrstni red« ju odstrani.|Vrstni red izračunov ima {n} vnose, ki ne izračunajo ničesar. »Popravi vrstni red« jih odstrani.|Vrstni red izračunov ima {n} vnosov, ki ne izračunajo ničesar. »Popravi vrstni red« jih odstrani. +@plural {fields} reads it|{fields} read it {fields} ga bere|{fields} ga bereta|{fields} ga berejo|{fields} ga berejo +@plural {n} field shows an old value|{n} fields show old values {n} polje kaže staro vrednost|{n} polji kažeta stari vrednosti|{n} polja kažejo stare vrednosti|{n} polj kaže stare vrednosti +@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} 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/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 72c7df714f..e0d6a2b34c 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -496,6 +496,36 @@ comment menu Edit సవరించు Could not save: {e} సేవ్ చేయడం సాధ్యం కాలేదు: {e} Earlier in tab order ట్యాబ్ క్రమంలో ముందు Later in tab order ట్యాబ్ క్రమంలో తర్వాత + Calculation order గణన క్రమం + Fix order క్రమాన్ని సరిచేయండి + Move only what must move, so every field calculates after the fields it reads. ప్రతి ఫీల్డ్ అది చదివే ఫీల్డ్‌ల తర్వాత లెక్కించబడేలా, తప్పనిసరిగా కదలాల్సినవి మాత్రమే కదిలిస్తుంది. + Earlier in calculation order గణన క్రమంలో ముందు + Later in calculation order గణన క్రమంలో తర్వాత + Too early చాలా ముందుగా + Reads {name}, which calculates later, so it shows an old value. {name}ను చదువుతుంది, అది తర్వాత లెక్కించబడుతుంది, కాబట్టి పాత విలువను చూపుతుంది. + Loop లూప్ + 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 వీక్షకులు దీన్ని లెక్కించకపోవచ్చు. + 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} + {field} reads no other field {field} మరే ఫీల్డ్‌నూ చదవదు + {field} reads {fields} {field} {fields}ని చదువుతుంది + No field reads it దాన్ని ఏ ఫీల్డ్ చదవదు + The order saved in the PDF needs tidying PDFలో సేవ్ చేసిన క్రమాన్ని సరిదిద్దాలి + Moves only what must move తప్పనిసరిగా జరపాల్సినవాటినే జరుపుతుంది + Every field calculates after the fields it reads. ప్రతి ఫీల్డ్ తాను చదివే ఫీల్డ్‌ల తర్వాత గణించబడుతుంది. + Every field outside a loop calculates after the fields it reads. లూప్ బయట ఉన్న ప్రతి ఫీల్డ్ తాను చదివే ఫీల్డ్‌ల తర్వాత గణించబడుతుంది. + {field} reads its own value {field} తన సొంత విలువను చదువుతుంది + {fields} read each other {fields} ఒకదానినొకటి చదువుతాయి + No order can fix a loop. Change one of the formulas. ఏ క్రమమూ లూప్‌ను సరిచేయలేదు. సూత్రాల్లో ఒకదాన్ని మార్చండి. + Edit {field}'s formula {field} సూత్రాన్ని సవరించు + Order · top calculates first క్రమం · పైది ముందుగా గణించబడుతుంది + Script స్క్రిప్ట్ + Links on page పేజీలో లింక్‌లు + Show on the page which fields each field reads ప్రతి ఫీల్డ్ ఏ ఫీల్డ్‌లను చదువుతుందో పేజీలో చూపించు + Drag a field to move it, or select it and press {up} or {down}. ఫీల్డ్‌ను జరపడానికి లాగండి, లేదా దాన్ని ఎంచుకుని {up} లేదా {down} నొక్కండి. From: కనిష్ఠం: To: గరిష్ఠం: High Resolution అధిక రిజల్యూషన్ @@ -2244,6 +2274,12 @@ animation effects Off ఆఫ్ no output folder was chosen అవుట్‌పుట్ ఫోల్డర్ ఎంచుకోలేదు @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF డౌన్‌లోడ్ చేయబడింది|{n} PDFలు డౌన్‌లోడ్ చేయబడ్డాయి @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} గణన ఇప్పుడు కొత్త పేరును ఉపయోగిస్తుంది.|{n} గణనలు ఇప్పుడు కొత్త పేరును ఉపయోగిస్తాయి. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. గణన క్రమంలో ఏదీ లెక్కించని {n} ఎంట్రీ ఉంది. "క్రమాన్ని సరిచేయండి" దాన్ని తొలగిస్తుంది.|గణన క్రమంలో ఏదీ లెక్కించని {n} ఎంట్రీలు ఉన్నాయి. "క్రమాన్ని సరిచేయండి" వాటిని తొలగిస్తుంది. +@plural {fields} reads it|{fields} read it {fields} దాన్ని చదువుతుంది|{fields} దాన్ని చదువుతాయి +@plural {n} field shows an old value|{n} fields show old values {n} ఫీల్డ్ పాత విలువను చూపుతోంది|{n} ఫీల్డ్‌లు పాత విలువలను చూపుతున్నాయి +@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 వ్యూయర్‌లు వాటిని గణించకపోవచ్చు. 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 69546ed05d..48d34a30da 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -477,6 +477,36 @@ Could not save: {e} Kaydedilemedi: {e} Earlier in tab order Sekme sırasında daha önce Later in tab order Sekme sırasında daha sonra + Calculation order Hesaplama sırası + Fix order Sırayı düzelt + Move only what must move, so every field calculates after the fields it reads. Her alan okuduğu alanlardan sonra hesaplansın diye yalnızca taşınması gerekenleri taşır. + Earlier in calculation order Hesaplama sırasında daha önce + Later in calculation order Hesaplama sırasında daha sonra + Too early Çok erken + Reads {name}, which calculates later, so it shows an old value. Daha sonra hesaplanan {name} alanını okur, bu yüzden eski bir değer gösterir. + Loop Döngü + 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. + 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} + {field} reads no other field {field} başka bir alanı okumuyor + {field} reads {fields} {field} şunları okur: {fields} + No field reads it Onu hiçbir alan okumuyor + The order saved in the PDF needs tidying PDF'te kayıtlı sıranın düzenlenmesi gerekiyor + Moves only what must move Yalnızca taşınması gerekeni taşır + Every field calculates after the fields it reads. Her alan, okuduğu alanlardan sonra hesaplanır. + Every field outside a loop calculates after the fields it reads. Döngü dışındaki her alan, okuduğu alanlardan sonra hesaplanır. + {field} reads its own value {field} kendi değerini okuyor + {fields} read each other {fields} birbirini okuyor + No order can fix a loop. Change one of the formulas. Hiçbir sıra bir döngüyü düzeltemez. Formüllerden birini değiştirin. + Edit {field}'s formula {field} formülünü düzenle + Order · top calculates first Sıra · üstteki önce hesaplanır + Script Betik + Links on page Sayfadaki bağlantılar + Show on the page which fields each field reads Her alanın hangi alanları okuduğunu sayfada göster + Drag a field to move it, or select it and press {up} or {down}. Taşımak için bir alanı sürükleyin veya seçip {up} ya da {down} tuşuna basın. From: Başlangıç: To: Bitiş: High Resolution Yüksek Çözünürlük @@ -2328,6 +2358,12 @@ animation effects Off Kapalı no output folder was chosen çıktı klasörü seçilmedi @plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF indirildi @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} hesaplama artık yeni adı kullanıyor. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Hesaplama sırasında hiçbir şey hesaplamayan {n} giriş var. "Sırayı düzelt" bunları kaldırır. +@plural {fields} reads it|{fields} read it Onu {fields} okuyor +@plural {n} field shows an old value|{n} fields show old values {n} alan eski bir değer gösteriyor +@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} 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 58094f7c43..246a4a2c56 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -517,6 +517,36 @@ Could not save: {e} Не вдалося зберегти: {e} Earlier in tab order Раніше в порядку табуляції Later in tab order Пізніше в порядку табуляції + Calculation order Порядок обчислень + Fix order Виправити порядок + Move only what must move, so every field calculates after the fields it reads. Переміщує лише те, що потрібно, щоб кожне поле обчислювалося після полів, які воно читає. + Earlier in calculation order Раніше в порядку обчислень + Later in calculation order Пізніше в порядку обчислень + Too early Зарано + Reads {name}, which calculates later, so it shows an old value. Читає {name}, яке обчислюється пізніше, тому показує старе значення. + Loop Цикл + 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 можуть його не обчислювати. + 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} + {field} reads no other field {field} не читає інших полів + {field} reads {fields} {field} читає {fields} + No field reads it Жодне поле його не читає + The order saved in the PDF needs tidying Порядок, збережений у PDF, потребує впорядкування + Moves only what must move Переміщує лише те, що треба + Every field calculates after the fields it reads. Кожне поле обчислюється після полів, які воно читає. + Every field outside a loop calculates after the fields it reads. Кожне поле поза циклом обчислюється після полів, які воно читає. + {field} reads its own value {field} читає власне значення + {fields} read each other {fields} читають одне одного + No order can fix a loop. Change one of the formulas. Жоден порядок не виправить цикл. Змініть одну з формул. + Edit {field}'s formula Змінити формулу {field} + Order · top calculates first Порядок · верхнє обчислюється першим + Script Скрипт + Links on page Зв’язки на сторінці + Show on the page which fields each field reads Показувати на сторінці, які поля читає кожне поле + Drag a field to move it, or select it and press {up} or {down}. Перетягніть поле, щоб перемістити його, або виберіть його й натисніть {up} чи {down}. From: Від: To: До: High Resolution Висока роздільна здатність @@ -2365,6 +2395,12 @@ animation effects Off Вимкнено no output folder was chosen не вибрано теку для виводу @plural Downloaded {n} PDF|Downloaded {n} PDFs Завантажено {n} PDF-файл|Завантажено {n} PDF-файли|Завантажено {n} PDF-файлів @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} обчислення тепер використовує нову назву.|{n} обчислення тепер використовують нову назву.|{n} обчислень тепер використовують нову назву. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. У порядку обчислень є {n} запис, який нічого не обчислює. «Виправити порядок» його видалить.|У порядку обчислень є {n} записи, які нічого не обчислюють. «Виправити порядок» їх видалить.|У порядку обчислень є {n} записів, які нічого не обчислюють. «Виправити порядок» їх видалить. +@plural {fields} reads it|{fields} read it {fields} читає його|{fields} читають його|{fields} читають його +@plural {n} field shows an old value|{n} fields show old values {n} поле показує старе значення|{n} поля показують старі значення|{n} полів показують старі значення +@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 можуть їх не обчислити. 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 f6e693a231..56caf38859 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -681,6 +681,36 @@ theme Appearance Giao diện Larger pages Trang lớn hơn Later Để sau Later in tab order Muộn hơn theo thứ tự tab + Calculation order Thứ tự tính toán + Fix order Sửa thứ tự + Move only what must move, so every field calculates after the fields it reads. Chỉ di chuyển những gì cần di chuyển, để mỗi trường được tính sau các trường mà nó đọc. + Earlier in calculation order Sớm hơn theo thứ tự tính toán + Later in calculation order Muộn hơn theo thứ tự tính toán + Too early Quá sớm + Reads {name}, which calculates later, so it shows an old value. Đọc {name}, trường được tính sau, nên hiển thị giá trị cũ. + Loop Vòng lặp + 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. + 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} + {field} reads no other field {field} không đọc trường nào khác + {field} reads {fields} {field} đọc {fields} + No field reads it Không trường nào đọc nó + The order saved in the PDF needs tidying Thứ tự lưu trong PDF cần được sắp xếp lại + Moves only what must move Chỉ di chuyển những gì cần thiết + Every field calculates after the fields it reads. Mọi trường đều được tính sau các trường mà nó đọc. + Every field outside a loop calculates after the fields it reads. Mọi trường ngoài vòng lặp đều được tính sau các trường mà nó đọc. + {field} reads its own value {field} đọc chính giá trị của nó + {fields} read each other {fields} đọc lẫn nhau + No order can fix a loop. Change one of the formulas. Không thứ tự nào sửa được vòng lặp. Hãy thay đổi một trong các công thức. + Edit {field}'s formula Sửa công thức của {field} + Order · top calculates first Thứ tự · trên cùng được tính trước + Script Tập lệnh + Links on page Liên kết trên trang + Show on the page which fields each field reads Hiện trên trang các trường mà mỗi trường đọc + Drag a field to move it, or select it and press {up} or {down}. Kéo một trường để di chuyển, hoặc chọn nó rồi nhấn {up} hoặc {down}. Layers Các lớp Layers Panel and Page Bảng Các lớp và trang Layout and Magnification Bố cục và độ phóng đại @@ -2590,6 +2620,12 @@ certificate SHA-256 digest Giá trị băm SHA-256 @plural {n} object selected|{n} objects selected Đã chọn {n} đối tượng @plural Downloaded {n} PDF|Downloaded {n} PDFs Đã tải xuống {n} PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} phép tính giờ dùng tên mới. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. Thứ tự tính toán có {n} mục không tính gì. "Sửa thứ tự" sẽ xóa chúng. +@plural {fields} reads it|{fields} read it {fields} đọc nó +@plural {n} field shows an old value|{n} fields show old values {n} trường hiển thị giá trị cũ +@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. 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 e8e91eb70c..645c0ab301 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -197,6 +197,36 @@ Could not save: {e} 无法保存:{e} Earlier in tab order 在 Tab 顺序中前移 Later in tab order 在 Tab 顺序中后移 + Calculation order 计算顺序 + Fix order 修正顺序 + Move only what must move, so every field calculates after the fields it reads. 只移动必须移动的字段,使每个字段都在它读取的字段之后计算。 + Earlier in calculation order 在计算顺序中前移 + Later in calculation order 在计算顺序中后移 + Too early 过早 + Reads {name}, which calculates later, so it shows an old value. 读取 {name},而它在之后才计算,因此显示的是旧值。 + Loop 循环 + 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 查看器可能不会计算它。 + 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} + {field} reads no other field {field} 不读取其他字段 + {field} reads {fields} {field} 读取 {fields} + No field reads it 没有字段读取它 + The order saved in the PDF needs tidying PDF 中保存的顺序需要整理 + Moves only what must move 只移动必须移动的 + Every field calculates after the fields it reads. 每个字段都在它读取的字段之后计算。 + Every field outside a loop calculates after the fields it reads. 循环之外的每个字段都在它读取的字段之后计算。 + {field} reads its own value {field} 读取自身的值 + {fields} read each other {fields} 相互读取 + No order can fix a loop. Change one of the formulas. 任何顺序都无法修复循环。请修改其中一个公式。 + Edit {field}'s formula 编辑 {field} 的公式 + Order · top calculates first 顺序 · 上方的先计算 + Script 脚本 + Links on page 页面上的关联 + Show on the page which fields each field reads 在页面上显示每个字段读取哪些字段 + Drag a field to move it, or select it and press {up} or {down}. 拖动字段以移动它,或选中它后按 {up} 或 {down}。 From: 从: To: 至: High Resolution 高分辨率 @@ -2307,6 +2337,12 @@ animation effects Off 关 no output folder was chosen 未选择输出文件夹 @plural Downloaded {n} PDF|Downloaded {n} PDFs 已下载 {n} 个 PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 个计算已改用新名称。 +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. 计算顺序中有 {n} 个不计算任何内容的条目。“修正顺序”会将其删除。 +@plural {fields} reads it|{fields} read it {fields} 读取它 +@plural {n} field shows an old value|{n} fields show old values {n} 个字段显示旧值 +@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 查看器可能不会计算。 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 16a9b04aa6..712f0666a5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -153,6 +153,36 @@ Could not save: {e} 無法儲存:{e} Earlier in tab order 在 Tab 順序中前移 Later in tab order 在 Tab 順序中後移 + Calculation order 計算順序 + Fix order 修正順序 + Move only what must move, so every field calculates after the fields it reads. 只移動必須移動的欄位,讓每個欄位都在它讀取的欄位之後計算。 + Earlier in calculation order 在計算順序中前移 + Later in calculation order 在計算順序中後移 + Too early 過早 + Reads {name}, which calculates later, so it shows an old value. 讀取 {name},而它之後才計算,因此顯示的是舊值。 + Loop 迴圈 + 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 檢視器可能不會計算它。 + 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} + {field} reads no other field {field} 不讀取其他欄位 + {field} reads {fields} {field} 讀取 {fields} + No field reads it 沒有欄位讀取它 + The order saved in the PDF needs tidying PDF 中儲存的順序需要整理 + Moves only what must move 只移動必須移動的 + Every field calculates after the fields it reads. 每個欄位都在它讀取的欄位之後計算。 + Every field outside a loop calculates after the fields it reads. 迴圈之外的每個欄位都在它讀取的欄位之後計算。 + {field} reads its own value {field} 讀取自身的值 + {fields} read each other {fields} 互相讀取 + No order can fix a loop. Change one of the formulas. 任何順序都無法修正迴圈。請修改其中一個公式。 + Edit {field}'s formula 編輯 {field} 的公式 + Order · top calculates first 順序 · 上方的先計算 + Script 指令碼 + Links on page 頁面上的關聯 + Show on the page which fields each field reads 在頁面上顯示每個欄位讀取哪些欄位 + Drag a field to move it, or select it and press {up} or {down}. 拖曳欄位即可移動,或選取後按 {up} 或 {down}。 From: 從: To: 至: High Resolution 高解析度 @@ -2206,6 +2236,12 @@ animation effects Off 關 no output folder was chosen 未選擇輸出資料夾 @plural Downloaded {n} PDF|Downloaded {n} PDFs 已下載 {n} 個 PDF @plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 個計算已改用新名稱。 +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. 計算順序中有 {n} 個不計算任何內容的項目。「修正順序」會將其刪除。 +@plural {fields} reads it|{fields} read it {fields} 讀取它 +@plural {n} field shows an old value|{n} fields show old values {n} 個欄位顯示舊值 +@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 檢視器可能不會計算。 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/lib.rs b/crates/ui-egui/src/lib.rs index ba4fc21fca..c3b882779c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ macro_rules! tl_ctx { mod a11y_ui; mod actions_ui; mod appearance; +mod calc_order_ui; pub mod canvas; mod chrome; mod combine_grid; @@ -188,6 +189,8 @@ impl Mode { pub enum LeftPanel { AllTools, Tool(&'static str), + /// Prepare a form ▸ Calculation order, a page of its own; Back returns to Prepare a form. + CalculationOrder, } /// Right-hand panels, opened from the rail. @@ -1088,10 +1091,12 @@ impl PdfCraftApp { } } - /// Fields are being edited (Prepare a form is open or a field tool is picked), unless the - /// form is being previewed. + /// Fields are being edited (Prepare a form or its Calculation order is open, or a field tool + /// is picked), unless the form is being previewed. pub fn is_preparing(&self) -> bool { - ((self.left_open && self.left == LeftPanel::Tool("form")) || matches!(self.quick_tool, QuickTool::Field(_))) && !self.form_preview + ((self.left_open && matches!(self.left, LeftPanel::Tool("form") | LeftPanel::CalculationOrder)) + || matches!(self.quick_tool, QuickTool::Field(_))) + && !self.form_preview } /// Open a document the way it asks to be opened: navigation panel, layout, magnification, diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 829ebad7e1..38d5ce557e 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -31,15 +31,20 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { Some(g) => tool_detail(app, ui, &t, g), None => app.left = LeftPanel::AllTools, }, + LeftPanel::CalculationOrder => crate::calc_order_ui::panel(app, ui, &t), }, ); } -fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool, bool) { +/// A panel's title row: Back (with `back` as its tooltip) when given, the title and Close. +/// Returns whether Back and Close were clicked. +pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: Option<&str>) -> (bool, bool) { let mut go_back = false; let mut close = false; ui.horizontal(|ui| { - if back && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { + if let Some(tip) = back + && icons::button(ui, "chevron-left", 26.0, false, tip).clicked() + { go_back = true; } // Reserve space for Close before laying out the translated title. Long tool names @@ -58,7 +63,7 @@ fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: bool) -> (bool } fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - let (_, close) = panel_header(ui, t, "All tools", false); + let (_, close) = panel_header(ui, t, "All tools", None); if close { app.left_open = false; } @@ -148,7 +153,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static edit_panel(app, ui, t, g); return; } - let (back, close) = panel_header(ui, t, g.label, true); + let (back, close) = panel_header(ui, t, g.label, Some(tl!("Back to all tools"))); if back { app.left = LeftPanel::AllTools; app.mode = crate::Mode::AllTools; diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index d257f9d2d2..25e70fb810 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -655,3 +655,157 @@ fn actions_tab_adds_and_removes_actions() { ] ); } + +/// Prepare a form ▸ Calculation order opens as a page of its own, with the form still being +/// prepared, says when nothing calculates, and Back returns to Prepare a form. +#[test] +fn the_calculation_order_is_a_page_of_prepare_a_form() { + use pdfcraft_ui_egui::LeftPanel; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.query_by_label("Field list").is_some()); + h.get_by_label("Calculation order").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::CalculationOrder); + assert!(h.state().is_preparing(), "the fields stay editable, so a row can select one"); + // The page replaces the Prepare a form tools. + assert!(h.query_by_label("Field list").is_none()); + assert!(h.query_by_label_contains("No field in this form calculates its value yet").is_some()); + h.get_by_label("Back to Prepare a form").click(); + h.run_steps(3); + assert_eq!(h.state().left, LeftPanel::Tool("form")); + assert!(h.query_by_label("Field list").is_some()); + assert!(h.query_by_label("Back to Prepare a form").is_none()); + // Opened from Preview, the page edits the fields again. + h.get_by_label("Preview").click(); + h.run_steps(3); + assert!(h.state().form_preview); + h.get_by_label("Calculation order").click(); + h.run_steps(3); + assert!(h.state().is_preparing() && !h.state().form_preview, "Preview ends, so a row can select its field"); + // Opened (from ⌘K) while the page grid shows, the grid closes and the page shows. + h.state_mut().execute("page.organize"); + h.run_steps(3); + assert!(h.state().views[0].organize); + h.state_mut().execute("form.calculation_order"); + h.run_steps(3); + assert!(!h.state().views[0].organize); + assert!(h.query_by_label_contains("No field in this form calculates its value yet").is_some()); +} + +/// Prepare a form ▸ Calculation order: a card says which field shows an old value and why, each +/// row shows its field's value as the field does, the selected field's arrows, Alt+↑ and a drag +/// move it, Fix order puts each field after the fields it reads and the card's Undo takes that +/// back, every change is one undo step, and a row selects its field. +#[test] +fn the_calculation_order_is_moved_dragged_and_fixed() { + use egui::{Key, Modifiers}; + use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format}; + use pdfcraft_engine::{Edit, FieldProps, FieldValue, NewField}; + let mut h = harness(); + for (i, name) in ["qty", "sub", "total", "other"].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 calculate = |name: &str, c: Calculate| Edit::SetFieldProps { + name: name.into(), + props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }), + }; + // Defined backwards: total, which reads sub, calculates first. + h.state_mut().apply_edit(calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })); + h.state_mut().apply_edit(calculate("sub", Calculate::Notation("qty * 2".into()))); + h.state_mut().apply_edit(calculate("other", Calculate::Notation("qty + 1".into()))); + // other shows its value as currency. + let currency = Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true }; + h.state_mut() + .apply_edit(Edit::SetFieldProps { name: "other".into(), props: Box::new(FieldProps { format: Some(currency), ..Default::default() }) }); + h.state_mut().apply_edit(Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + h.get_by_label("Calculation order").click(); + h.run_steps(3); + let order = |h: &Harness<'static, PdfCraftApp>| -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().calculation_order().fields.into_iter().map(|f| f.name).collect() + }; + let undo_label = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().can_undo().map(str::to_owned); + assert_eq!(order(&h), ["total", "sub", "other"]); + // The card says what is wrong, and each row shows its value as the field does: total + // calculated before sub, so it still has sub's old value. + assert!(h.query_by_label("1 field shows an old value").is_some()); + assert!(h.query_by_label("total calculates before sub, which it reads.").is_some()); + for row in ["1. total: 0", "2. sub: 6", "3. other: $4.00"] { + assert!(h.query_by_label(row).is_some(), "{row}"); + } + // The selected field's arrows move it. + assert!(h.query_by_label("Later in calculation order: total").is_none(), "arrows only on the selected field or the one pointed at"); + h.get_by_label("1. total: 0").click(); + h.run_steps(3); + h.get_by_label("Later in calculation order: total").click(); + h.run_steps(3); + assert_eq!(order(&h), ["sub", "total", "other"]); + assert_eq!(undo_label(&h).as_deref(), Some("Set calculation order")); + // Alt+↑ moves it back. + h.key_press_modifiers(Modifiers::ALT, Key::ArrowUp); + h.run_steps(3); + assert_eq!(order(&h), ["total", "sub", "other"]); + // Dragging other onto the first row puts it first. + let from = h.get_by_label("3. other: $4.00").rect().center(); + let to = h.get_by_label("1. total: 6").rect().center(); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); + assert_eq!(order(&h), ["other", "total", "sub"]); + // Fix order moves only total, after sub, and the card says the order is right. + h.get_by_label("Fix order").click(); + h.run_steps(3); + assert_eq!(order(&h), ["other", "sub", "total"]); + assert_eq!(undo_label(&h).as_deref(), Some("Set calculation order")); + assert!(h.query_by_label("Every field calculates after the fields it reads.").is_some()); + assert!(h.query_by_label("Fix order").is_none()); + // Its Undo takes Fix order back. + h.get_by_label("Undo").click(); + h.run_steps(3); + assert_eq!(order(&h), ["other", "total", "sub"]); + // A row selects its field. + h.get_by_label("3. sub: 6").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|s| s.0.as_str()), Some("sub")); +} + +/// Fields that read each other get a card of their own, which Fix order leaves alone, and its +/// link opens the first one's formula. +#[test] +fn a_loop_has_its_own_card_that_opens_the_formula() { + 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, formula)) in [("a", "b + 1"), ("b", "a + 1")].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(formula.into())), ..Default::default() }); + h.state_mut().apply_edit(Edit::SetFieldProps { name: name.into(), props }); + } + 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("a and b read each other").is_some()); + assert!(h.query_by_label("Every field outside a loop calculates after the fields it reads.").is_some()); + assert!(h.query_by_label("Fix order").is_none(), "no order fixes a loop"); + h.get_by_label("Edit a'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(("a".to_owned(), FieldTab::Calculate))); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 7407ecbd59..201cc1d6c3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -143,7 +143,7 @@ total matches the parity estimate. | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. The calculation order is read, checked, fixed and set (`form_calculation_order`): fields that calculate too early, loops, fields missing from `/CO`; Fix order moves only what must move, and an edited formula keeps its place. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | @@ -202,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. | | 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. | | 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f165b33165..ad00b0115d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4594,10 +4594,10 @@ area = "F" tier = "P0" milestone = "M6" status = "shipped" -commands = ["form.field.properties"] +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/automation.rs::the_calculation_order_is_read_fixed_and_set"] -notes = "/CO order, then other calculated fields; recalculated after every fill and Clear form. form_calculation_order reads the order with what each formula reads, says which fields read a field calculated later, which read each other in a loop and which /CO leaves out, and sets it, moves one field (as the Up/Down of Acrobat's Set Field Calculation Order dialog) or fixes it (Fix order moves only what must move). A new calculation joins the end of /CO and an edited one keeps its place. The app has no way to see or change the order yet. 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/automation.rs::the_calculation_order_is_read_fixed_and_set", "crates/ui-egui/tests/prepare.rs::the_calculation_order_is_moved_dragged_and_fixed"] +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." [[feature]] id = "form.event-order" From d676fd44ea99b277d508455a7e107e4aa1346323 Mon Sep 17 00:00:00 2001 From: Piohrun Date: Thu, 22 Oct 2026 03:27:31 +0200 Subject: [PATCH 19/20] =?UTF-8?q?M6:=20Prepare=20a=20form=20=E2=96=B8=20Te?= =?UTF-8?q?xt=20field=20table:=20rows=20and=20columns=20of=20text=20fields?= =?UTF-8?q?=20as=20one=20edit=20(#3479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M6: Prepare a form ▸ Text field table: rows and columns of text fields as one edit Drag an area with the Text field table tool and set it up in a movable window: rows, columns, row and column spacing, the first row number, column widths (draggable dividers, arrow keys once focused) and a name pattern per column, with the table previewed on the page. Add fields adds every cell as one undoable edit ("Add text field table"). - forms: `table` lays the table out in view space and names its cells. It refuses the whole table for a cell under MIN_FIELD_SIDE (now one constant) or a name with a period, used twice or already taken, and free_base picks the first free TableK for default names. - engine: `field_table::edit` maps the cells onto the page, /Rotate and /UserUnit included, and checks their size again there. - automation: `form_add_field_table`, with an end-to-end test. - ui-egui: the window belongs to one page of one document and closes when that stops being the one prepared; Enter commits a number instead of adding the table; dividers between many columns no longer panic in f64::clamp. Strings in all 22 catalogs, and the Spanish engine-wording tests cover the table's mistakes. - parity: form.multiple-copies is partial; copying an existing field into a grid is still missing. Co-Authored-By: Claude Opus 5.5 * M6: text field table: refuse corners past the page's number range, run it by its command Review follow-up on #3479. - engine: a corner finite as an f64 but past f32's range came back from the page's f32 mapping infinite. With one such corner a side was infinite rather than NaN, so the cell passed and add_field refused it only once the edit was applied ("the field is too small"). The edit now refuses any corner that isn't finite on the page with the table's own reason, before anything is added. - automation: form.add.text_table runs form_add_field_table through command_run, as comment.stamp runs stamp_custom. - ui-egui: doc comments for the window's three remaining helpers. Co-Authored-By: Claude Opus 5.5 * M6: text field table: close on document edits, lay out once per change, keep Escape for fields Review follow-up on #3479 (benandez). - The window belongs to the document as it was when it opened: it closes when the edit generation moves (a page rotated, moved or deleted, an edit undone), so a table set up for one page never lands on another or off it. - The table is laid out and checked once per change, not several times a frame: the window and the page preview share one `Laid` (cells and the edit), and the preview draws only the cells in view, naming only those with room for a name. - Escape in a field of the window leaves the field and keeps the setup; a second Escape closes the window. - A table placed with a click is moved in from the page's edges, so all of it is on the page. - `free_base` takes one pass over the names; `{seq}` is documented as counting from the first row number; `form_add_field_table` takes its names from the batch instead of laying the table out again; the unreachable `Dialog::FieldTable` arm says why it is there. Each new test fails with its fix reverted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * M6: text field table: drawn with its panel closed, the window opens Prepare a form instead of closing With the tool picked and the Prepare a form panel closed, the form is still prepared and a table can be drawn. Drawing sends the tool back to Select, though, so nothing was prepared on the next frame, and the table window, which lasts only while the form is prepared, closed itself at once: the table couldn't be set up. Opening the window now opens the Prepare a form panel it belongs to. Closing the panel while the window is open still closes the window. Found by CodeRabbit, which saw the preview stop drawing; the window closing first was the larger effect. prepare.rs a_table_drawn_with_the_panel_closed_opens_it_again fails without the fix ("the window stays open": (None, false)). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BfMGHQNgdq5yf5tKZHGR3z * Drop the second generation binding the main merge left in document_area --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: aiongg --- ATTRIBUTION.toml | 44 +- ROADMAP.md | 1 + crates/automation/src/forms.rs | 63 ++- crates/automation/src/lib.rs | 1 + crates/automation/src/tools.rs | 21 + crates/automation/tests/automation.rs | 59 +++ crates/engine/src/catalog.rs | 1 + crates/engine/src/commands.rs | 1 + crates/engine/src/field_table.rs | 131 +++++ crates/engine/src/lib.rs | 4 +- crates/forms/README.md | 8 + crates/forms/src/author.rs | 9 +- crates/forms/src/lib.rs | 6 +- crates/forms/src/table.rs | 663 ++++++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 19 +- crates/ui-egui/src/dialogs.rs | 11 + crates/ui-egui/src/field_table_ui.rs | 512 ++++++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 20 + crates/ui-egui/src/i18n/bg.tsv | 20 + crates/ui-egui/src/i18n/cs.tsv | 20 + crates/ui-egui/src/i18n/de.tsv | 20 + crates/ui-egui/src/i18n/el.tsv | 20 + crates/ui-egui/src/i18n/es.tsv | 24 + crates/ui-egui/src/i18n/fr.tsv | 20 + crates/ui-egui/src/i18n/hu.tsv | 20 + crates/ui-egui/src/i18n/it.tsv | 24 + crates/ui-egui/src/i18n/ja.tsv | 20 + crates/ui-egui/src/i18n/kk.tsv | 20 + crates/ui-egui/src/i18n/lt.tsv | 24 + crates/ui-egui/src/i18n/mod.rs | 8 +- crates/ui-egui/src/i18n/pl.tsv | 20 + crates/ui-egui/src/i18n/pt-br.tsv | 20 + crates/ui-egui/src/i18n/ru.tsv | 20 + crates/ui-egui/src/i18n/sl.tsv | 20 + crates/ui-egui/src/i18n/te.tsv | 20 + crates/ui-egui/src/i18n/tr.tsv | 20 + crates/ui-egui/src/i18n/uk.tsv | 20 + crates/ui-egui/src/i18n/vi.tsv | 24 + crates/ui-egui/src/i18n/zh-hans.tsv | 20 + crates/ui-egui/src/i18n/zh-hant.tsv | 20 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/prepare.rs | 44 +- crates/ui-egui/tests/prepare.rs | 233 ++++++++- docs/roadmap.md | 3 +- parity/acrobat-features.toml | 15 +- 45 files changed, 2274 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/field_table.rs create mode 100644 crates/forms/src/table.rs create mode 100644 crates/ui-egui/src/field_table_ui.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index c5e55f0631..23ecc1903b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "53b57cc89461ecc4d48350a4b2ef969c9ba138048a8e28d6a8ee45d549847e1f" +sha256 = "ba26246db8ac2a7262fa720b6c5da9e0ae1b3f5198dc29aa260cf274d332ba27" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1d4b5c40d08e0c3ee328ffee487ff4133627381cf865d21bec682c247e6628dc" +sha256 = "a1182f567b2ce1626d3ea84ad6c91eb26e1de5d0eb0628f68f9f200356bf6435" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11045efab47ed4a962861f0e3e9568836a8e9ece9ea2e0a4ec58ccf78d2e677f" +sha256 = "f309799de30c6652e49cbfd667cb2387964caa56344dd06d645bb6e6c2a552ca" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb4c53d7b938a5ec8eb3a001a6c385c60b84f9f1ac36240c490d164046757c2a" +sha256 = "33db8b928d3729f9e5f1730efe51124141055a63979903a9b9116434045f8407" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "af4b798c568cb1bb44ef23700e2d69c1e91c7cbb81e6fb565079a797cf02c878" +sha256 = "0fce5a7bdb47afd40de8843dae28370e4992fe57d9944d0111957ff13b2ed3f8" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8fe572236493148b23270ef5766ad1c7d65a58b293757f34772d01a44a2df026" +sha256 = "54cb7447058bb3d5865d1a05eddcf4fe78b22fdce0daea7aba45a0fe51134436" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6af18efaa6fc8d7d9e813b4411380f1f47879c3e342d800540abec5cd6cb8f81" +sha256 = "df75aa84138a0b7d4eeaae4a24e2f8e85079ce1887efc01cb3107304ff6478dc" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2b7787232635373d44b16b93051763b01cf8fa9e6877c87c013ad42171fdc915" +sha256 = "b34a7c2e9d02d357cb214a7c4d5d79b599576b9e5174d5069e6dae81eb6d340b" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9f67da0f5b455cf99be298e8318721ad6c8d306223e74ad42faf6edd2f8a839b" +sha256 = "b8d743ccbd2f628beed04690b810038b62f2da0fedde63e8335b3d171491d3f0" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f7765338632fee507a907cbabc52f72d57d903839f8a4d48b34e2cdcbb28a3bc" +sha256 = "ad2f8f099470ae2a028ff51ff6a073691b4f15e0d933f11d1f38712128b45acd" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "62517afff28af384a42aa377b86582ecddfe8dc8453f06c481246e96184add59" +sha256 = "27b98fe44cf5c19279dec4b948ebf86e2a87ef4601ca4013cf2d5a2c7ec6d77a" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d46ff4a0505c6374008d55fc4112d4a41508c333411551dad8f6102243bbcccd" +sha256 = "2e4af3ed74f41f9d47fcce5280f063b9dde86173f56d510aded3d98ec73ca4dd" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "61f9efbc72c1e597cdb9a2892bb87e4d0b64c1a7736bc8f7d719e9f18c85da12" +sha256 = "46d2f5913acce24178ab186e7b9ddf3ed60e5f4a738920d51fa9bf1137cb4dcf" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "90fac37b3f4c69d16841fec2bc30b66aa3cd0964a493ebdac4f71e40dc424164" +sha256 = "81c2bd98a5fb913c809dc61cfd1f7c65421c605808cb4d7cfe918447211da0b9" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18c15dd98f6efe9929d6b00360dc6d15a44bbe6bfde044688bc911011868efeb" +sha256 = "5216653fbce9f75255b31ef6c12b75c3c0fbfedfeec6806eef1afa22b3229a70" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "07b222d5b3fdd155ba57cec323dc9ca290c6e01331bb32f7181f21728362d53b" +sha256 = "02e29bbefbd593c44bf1efa12282d7462b67f7740e03b70dbad2c796d99def89" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b15b5fdca526de6d39d4a22b0c4d54f13eb5df0691ef6c0ea5e4987122ea45be" +sha256 = "1c6672a67f7e7fbbf015c4604f5706e5f0736145a24488b3af4fe2c7231073b6" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "882c07204a95bef324f67f891e76dae0a1a499a420e07929490e6b7f62cf4487" +sha256 = "6a25bd21de4ce538be59aec5456d43a2e33d20e4e63ae7b933b1c6ceb4349224" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7afdd36b96ac5b9c4c1023f506b98af9c559fe85ea00eb7e9518eda9fe77850b" +sha256 = "7474bd8d39966455d5275ceda701139ac392e02391ac9bc6c30f48336041bd80" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786" +sha256 = "0bb73d88f61e5552008c4d807d3b8b2f1547281d1f68b26458029407eacefcf6" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2c352743eaeec21eb9e522c71c43f3f6df34b81f024a5c72c85332e2ecc2b338" +sha256 = "53edd35cad3ccf835f7a2de3235c16a77da1a5cde1ac4a18d275de9031717391" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a212a56fd316e58349845c750e935b28ba5074b3fd096016c3ae92cbe502648f" +sha256 = "0452511f97f17bfa97e63de585bc5b973e1aceec98dd0c3cc5a256414f585a03" [[asset]] diff --git a/ROADMAP.md b/ROADMAP.md index 96f6ede4b9..cb2b36028b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,6 +175,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. +- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged. - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index baeb4dd521..0bcf29d826 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,5 +1,5 @@ //! Form tools: list fields, fill them (several at once, one undo step), clear the form, and -//! prepare a form (add, change and delete fields). +//! 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::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; @@ -339,6 +339,67 @@ impl Automation { Ok(out) } + /// A table of text fields: `rect` (points from the top-left of the displayed page) divided + /// into rows and columns, one field per cell, as one undo step. + pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result { + use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError}; + let page = self.page(a)?; + let rect = a + .get("rect") + .and_then(Value::as_array) + .and_then(|r| r.iter().map(Value::as_f64).collect::>>()) + .and_then(|r| <[f64; 4]>::try_from(r).ok()) + .ok_or_else(|| bad("rect must be 4 numbers"))?; + let names = a.get("names").map(|_| a.strs("names")).transpose()?; + let widths = match a.get("widths") { + None => None, + Some(v) => Some( + v.as_array() + .and_then(|w| w.iter().map(Value::as_f64).collect::>>()) + .ok_or_else(|| bad("widths must be an array of numbers"))?, + ), + }; + // A negative count is no count: the table refuses it with its own words. + let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)]; + let mut given = counts.into_iter().flatten(); + let columns = given.next().unwrap_or(1); + if given.any(|n| n != columns) { + return Err(bad("columns, names and widths must agree on the number of columns")); + } + // Before anything is sized by it. + if !(1..=MAX_COLUMNS).contains(&columns) { + return Err(bad(TableError::Columns.to_string())); + } + let (edit, fields) = { + let doc = self.doc(a)?; + let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str())); + let mut table = FieldTable::new(rect, &base); + table.rows = usize::try_from(a.int("rows")?).unwrap_or(0); + table.columns = (0..columns) + .map(|c| TableColumn { + pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()), + width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0), + }) + .collect(); + table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0); + table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0); + table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX)); + let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?; + let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?; + // The names the batch adds, in the order it adds them. + let fields: Vec = match &edit { + Edit::Batch { edits, .. } => { + edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect() + } + _ => Vec::new(), + }; + (edit, fields) + }; + let mut out = self.apply(a, edit)?; + out["fields"] = json!(fields); + Ok(out) + } + pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { if a.get("widget").is_some() && a.get("caption").is_none() { return Err(bad("widget requires caption")); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 31bb8da6ea..fc00bdfbed 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -726,6 +726,7 @@ impl Automation { } "form_reset" => self.form_reset(&a)?, "form_add_field" => self.form_add_field(&a)?, + "form_add_field_table" => self.form_add_field_table(&a)?, "form_set_props" => self.form_set_props(&a)?, "form_delete_field" => self.form_delete_field(&a)?, "form_tab_order" => self.form_tab_order(&a)?, diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 59ecf04252..a6189fa5e2 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -355,6 +355,27 @@ pub fn tools() -> Vec { }), &["doc", "page", "type", "rect"], )), + t( + "form_add_field_table", + "Prepare form: add a table of text fields", + "Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.", + ) + .cmd("form.add.text_table") + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "rows": { "type": "integer", "minimum": 1, "maximum": 100 }, + "columns": { "type": "integer", "minimum": 1, "maximum": 50 }, + "names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." }, + "widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." }, + "row_gap": { "type": "number", "minimum": 0 }, + "column_gap": { "type": "number", "minimum": 0 }, + "first_row": { "type": "integer", "minimum": 0, "maximum": 99999 }, + }), + &["doc", "page", "rect", "rows"], + )), t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.") .cmd("comment.stamp") .with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 2c87162d5d..13bfb0b65e 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3391,6 +3391,65 @@ fn rotating_a_field_through_tools() { assert_eq!((rot, back), (0, before)); } +/// A table of text fields through the tool: laid out from the top of the page as displayed, named +/// column by column, one undo step, and refused whole, with the reason, when any of it can't go in. +#[test] +fn adding_a_field_table_through_tools() { + let dir = workdir("field-table"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let table = + json!({ "doc": doc, "page": 1, "rect": [20, 20, 220, 120], "rows": 2, "names": ["desc_N", "price_N"], "widths": [3, 1], "row_gap": 10 }); + assert_eq!(ok(&mut a, "form_add_field_table", table.clone())["fields"], json!(["desc_1", "price_1", "desc_2", "price_2"])); + let rects = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().clone(); + fields.iter().map(|f| (f["name"].as_str().unwrap().to_owned(), f["rect"].clone())).collect::>() + }; + // 150 and 50 points wide, (100 - 10) / 2 = 45 high, the first row at the top. + let near = |v: &Value, want: [f64; 4]| v.as_array().unwrap().iter().zip(want).all(|(g, w)| (g.as_f64().unwrap() - w).abs() < 0.01); + let got = rects(&mut a); + for (name, want) in [ + ("desc_1", [20.0, 20.0, 170.0, 65.0]), + ("price_1", [170.0, 20.0, 220.0, 65.0]), + ("desc_2", [20.0, 75.0, 170.0, 120.0]), + ("price_2", [170.0, 75.0, 220.0, 120.0]), + ] { + let rect = got.iter().find(|(n, _)| n == name).map(|(_, r)| r).unwrap(); + assert!(near(rect, want), "{name}: {rect} != {want:?}"); + } + // Without names, each table takes the first free base. + let plain = json!({ "doc": doc, "page": 2, "rect": [20, 20, 220, 120], "rows": 2 }); + assert_eq!(ok(&mut a, "form_add_field_table", plain.clone())["fields"], json!(["Table1_1", "Table1_2"])); + assert_eq!(ok(&mut a, "form_add_field_table", plain)["fields"], json!(["Table2_1", "Table2_2"])); + let count = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(); + assert_eq!(count(&mut a), 8); + // Refused whole, saying why: nothing is added. + let refused = |a: &mut Automation, args: Value| match a.call("form_add_field_table", &args) { + Err(ToolError::InvalidArgs(m)) => m, + other => panic!("refused, not {other:?}"), + }; + assert_eq!(refused(&mut a, table), "a field named \"desc_1\" already exists"); + let small = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 30 }); + assert!(refused(&mut a, small).starts_with("the rows are too short")); + let dotted = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "names": ["a.{n}"] }); + assert_eq!(refused(&mut a, dotted), "field names can't contain a period: \"a.1\""); + let disagree = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 3, "names": ["a_N", "b_N"] }); + assert!(refused(&mut a, disagree).contains("must agree")); + let wide = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 51 }); + assert_eq!(refused(&mut a, wide), "a table has 1 to 50 columns"); + let none = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": -1 }); + assert_eq!(refused(&mut a, none), "a table has 1 to 100 rows"); + let huge = json!({ "doc": doc, "page": 1, "rect": [20, 200, 1e39, 300], "rows": 2 }); + assert!(refused(&mut a, huge).starts_with("the area, the spacing and the column widths must be numbers")); + assert_eq!(count(&mut a), 8); + // The table tool's command runs it too. + let by_command = json!({ "id": "form.add.text_table", "params": { "doc": doc, "page": 3, "rect": [20, 20, 220, 120], "rows": 2 } }); + assert_eq!(ok(&mut a, "command_run", by_command)["fields"], json!(["Table3_1", "Table3_2"])); + assert_eq!(count(&mut a), 10); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add text field table"); + assert_eq!(count(&mut a), 8, "one undo takes back the whole last table"); +} + #[test] fn redacting_through_tools() { let dir = workdir("redact"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 551cbdc67c..c19e1e42c8 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -357,6 +357,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ title: "Add form components", items: &[ item("Text field", "text-cursor-input", "form.add.text", Ready), + item("Text field table", "grid-3x3", "form.add.text_table", Ready), item("Checkbox", "check-circle-2", "form.add.checkbox", Ready), item("Radio button", "circle", "form.add.radio", Ready), item("Drop-down list", "chevron-down", "form.add.combo", Ready), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9958763d19..18be797d20 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -293,6 +293,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"), c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"), c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"), + c("form.add.text_table", "Add text field table", None, None, Modification, "grid-3x3"), c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"), c("form.add.radio", "Add a radio button", None, None, Modification, "circle"), c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"), diff --git a/crates/engine/src/field_table.rs b/crates/engine/src/field_table.rs new file mode 100644 index 0000000000..f3fd96551e --- /dev/null +++ b/crates/engine/src/field_table.rs @@ -0,0 +1,131 @@ +//! Prepare a form ▸ Text field table: one undoable edit adding a [`FieldTable`]'s cells as text +//! fields. The table is laid out and named in `pdfcraft_forms::table`; this maps its cells from +//! view space onto the page and refuses, before anything is added, what `add_field` would refuse. + +pub use pdfcraft_forms::table::*; + +use pdfcraft_forms::MIN_FIELD_SIDE; +use pdfcraft_render::PageInfo; + +use crate::{Edit, FormField, NewField}; + +/// The edit's name in the history ("Undo Add text field table"), one of [`crate::BATCH_LABELS`]. +pub const LABEL: &str = "Add text field table"; + +/// The edit adding `table`'s cells to `page` (0-based, shown as `info`) of a document whose +/// fields are `form`. Refused, with nothing added, when a cell would be too small on the page or +/// its name can't be used. +pub fn edit(table: &FieldTable, page: usize, info: &PageInfo, form: &[FormField]) -> Result { + let cells = table.cells_for(form.iter().map(|f| f.name.as_str()))?; + // A page turned a quarter runs down the screen along user space's x. + let sideways = matches!(info.rotation, 90 | 270); + let mut edits = Vec::with_capacity(cells.len()); + for cell in cells { + let [x0, y0, x1, y1] = cell.rect; + let rect = info.view_rect_to_user([x0 as f32, y0 as f32, x1 as f32, y1 as f32]).map(f64::from); + // A corner past `f32`'s range comes back infinite. + if !rect.iter().all(|v| v.is_finite()) { + return Err(TableError::Numbers); + } + // `add_field`'s own test, after the page's scale (`/UserUnit`) and rounding to `f32`. + let (across, down) = if sideways { (rect[3] - rect[1], rect[2] - rect[0]) } else { (rect[2] - rect[0], rect[3] - rect[1]) }; + if down < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + if across < MIN_FIELD_SIDE { + return Err(TableError::ColumnTooNarrow); + } + edits.push(Edit::AddField { page, rect, kind: NewField::Text { multiline: false }, name: Some(cell.name) }); + } + Ok(Edit::Batch { label: LABEL.to_owned(), edits }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Session; + + fn page(width: f32, height: f32, crop: [f32; 4], rotation: u16) -> PageInfo { + PageInfo { width, height, label: "1".into(), crop, rotation } + } + + fn grid(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable { + let mut t = FieldTable::new(rect, "unused"); + t.rows = rows; + t.columns = patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(); + t + } + + #[test] + fn a_table_is_added_and_undone_as_one_step() { + let mut s = Session::new(); + let bytes = s.create_blank(300.0, 400.0, 1).unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + // 200 × 100 points from (50, 40) down the page as displayed: two rows of two. + let table = grid([50.0, 40.0, 250.0, 140.0], 2, &["desc_N", "price_N"]); + let add = |s: &Session| { + let doc = s.get(id).unwrap(); + edit(&table, 0, &doc.info.pages[0], &doc.form) + }; + s.apply(id, add(&s).unwrap()).unwrap(); + let doc = s.get(id).unwrap(); + let rect = |name: &str| doc.form.iter().find(|f| f.name == name).map(|f| f.widgets[0].rect); + // User space has y up: the first row is the higher one. + assert_eq!(rect("desc_1"), Some([50.0, 310.0, 150.0, 360.0])); + assert_eq!(rect("price_1"), Some([150.0, 310.0, 250.0, 360.0])); + assert_eq!(rect("desc_2"), Some([50.0, 260.0, 150.0, 310.0])); + assert_eq!(rect("price_2"), Some([150.0, 260.0, 250.0, 310.0])); + // Laid out again over the same names, it is refused before anything is added. + assert_eq!(add(&s).err(), Some(TableError::Taken("desc_1".into()))); + assert_eq!(s.undo(id).unwrap().to_string(), LABEL); + assert!(s.get(id).unwrap().form.is_empty(), "one undo takes the whole table back"); + } + + #[test] + fn rows_run_down_a_turned_page_as_it_is_shown() { + let mut s = Session::new(); + let bytes = s.create_blank(300.0, 400.0, 1).unwrap(); + let id = s.open("t.pdf", None, bytes, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let doc = s.get(id).unwrap(); + let info = doc.info.pages[0].clone(); + assert_eq!((info.rotation, info.width, info.height), (90, 400.0, 300.0)); + let table = grid([40.0, 30.0, 240.0, 130.0], 2, &["a_N", "b_N"]); + s.apply(id, edit(&table, 0, &info, &doc.form).unwrap()).unwrap(); + // Each field, taken back to the page as shown, is where its cell was laid out. + let doc = s.get(id).unwrap(); + for cell in table.cells().unwrap() { + let r = doc.form.iter().find(|f| f.name == cell.name).unwrap().widgets[0].rect; + let (a, b) = (info.user_to_view(r[0] as f32, r[1] as f32), info.user_to_view(r[2] as f32, r[3] as f32)); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(f64::from); + assert!(shown.iter().zip(cell.rect).all(|(s, c)| (s - c).abs() < 1e-3), "{}: {shown:?} vs {:?}", cell.name, cell.rect); + } + } + + #[test] + fn a_cell_too_small_on_the_page_is_refused_along_the_right_side() { + // Half a point of user space for each point shown across, a whole one down: a quarter + // turn puts "across" on user space's y. + let info = page(200.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90); + let narrow = grid([0.0, 0.0, 12.0, 12.0], 2, &["a_N", "b_N"]); + assert!(narrow.cells().is_ok(), "6 × 6 shown"); + assert_eq!(edit(&narrow, 0, &info, &[]).err(), Some(TableError::ColumnTooNarrow)); + let unturned = page(100.0, 200.0, [0.0, 0.0, 100.0, 100.0], 0); + assert_eq!(edit(&narrow, 0, &unturned, &[]).err(), Some(TableError::RowsTooShort)); + let roomy = page(100.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90); + assert!(edit(&narrow, 0, &roomy, &[]).is_ok()); + } + + /// A corner finite as an `f64` but past `f32`'s range lays out, then comes back from the page's + /// `f32` mapping infinite: one such corner leaves a side infinite, not NaN, and the cell used to + /// pass, for `add_field` to refuse it once the edit was applied. + #[test] + fn a_table_past_the_pages_number_range_is_refused_before_anything_is_added() { + let huge = grid([0.0, 0.0, 1e39, 100.0], 2, &["a_N"]); + assert!(huge.cells().is_ok(), "finite as laid out"); + for rotation in [0, 90] { + let info = page(300.0, 400.0, [0.0, 0.0, 300.0, 400.0], rotation); + assert_eq!(edit(&huge, 0, &info, &[]).err(), Some(TableError::Numbers), "/Rotate {rotation}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2125a5b9d8..7a820c15ef 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod compare; mod copy_image; pub mod dates; pub mod export; +pub mod field_table; pub mod js; pub mod links; pub mod ocr; @@ -1952,7 +1953,8 @@ pub const EDIT_LABELS: &[&str] = &[ /// The names of the batches built below L7, which an interface shows in the same places as /// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives /// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"]; +pub const BATCH_LABELS: &[&str] = + &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL]; impl Edit { /// Label for the Edit menu and history ("Undo Rotate pages"). diff --git a/crates/forms/README.md b/crates/forms/README.md index b567009f8c..7df2cb9935 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -25,6 +25,14 @@ set_props(&mut doc, "email", &FieldProps { ..Default::default() })?; delete_field(&mut doc, "email")?; + +// A table of text fields (view space, y down), laid out and named before any of it is added: +// refused whole for a cell under MIN_FIELD_SIDE or a name with a period, repeated or taken. +let names = all.iter().map(|f| f.name.as_str()); +let base = table::free_base(names.clone()); // "Table1", or the next one free +let mut grid = table::FieldTable::new([40.0, 240.0, 240.0, 360.0], &base); +grid.set_columns(2, &base); +let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table1_c2_1", … ``` ## Behaviour diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 53c90b0fcc..c0353a3b9a 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -11,6 +11,9 @@ use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; +/// The smallest width and height of a field's widget, in points: a smaller one is refused. +pub const MIN_FIELD_SIDE: f64 = 4.0; + /// The kind of field to add. #[derive(Clone, Debug, PartialEq)] pub enum NewField { @@ -515,7 +518,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let pages = page_refs(doc); let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 { + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE { return invalid("the field is too small"); } let existing = fields(doc); @@ -856,7 +859,7 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); let (nw, nh) = (y1 - y0, x1 - x0); let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; - (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) + (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) } /// Change a field's properties (General and Options tabs) and redraw it. @@ -1097,7 +1100,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result, + /// Points left empty between two rows. + pub row_gap: f64, + /// Points left empty between two columns. + pub column_gap: f64, + /// The number the first row's names count from. + pub first_row: usize, +} + +/// One cell of a [`FieldTable`]: where its field goes (view space, `[x0, y0, x1, y1]` with y down) +/// and what it is called. +#[derive(Clone, Debug, PartialEq)] +pub struct Cell { + pub rect: [f64; 4], + pub name: String, +} + +impl FieldTable { + /// Four rows in one column filling `rect`, named after `base` (see [`column_pattern`]). + pub fn new(rect: [f64; 4], base: &str) -> Self { + FieldTable { + rect, + rows: 4, + columns: vec![TableColumn { pattern: column_pattern(base, 1), width: 1.0 }], + row_gap: 0.0, + column_gap: 0.0, + first_row: 1, + } + } + + /// Make the table `n` columns wide, `n` held to 1 to [`MAX_COLUMNS`]. Columns past `n` are + /// dropped; a new one is named after `base` and is as wide as the others are on average, so + /// the widths already set keep their proportions. + pub fn set_columns(&mut self, n: usize, base: &str) { + let n = n.clamp(1, MAX_COLUMNS); + self.columns.truncate(n); + let sum: f64 = self.columns.iter().map(|c| c.width).sum(); + let average = sum / self.columns.len().max(1) as f64; + let width = if average.is_finite() && average > 0.0 { average } else { 1.0 }; + while self.columns.len() < n { + let pattern = column_pattern(base, self.columns.len() + 1); + self.columns.push(TableColumn { pattern, width }); + } + } + + /// Where each cell goes and what it is called, row by row. Refuses a table out of bounds or + /// with a cell under [`MIN_FIELD_SIDE`]; the names are not checked (see [`Self::cells_for`]). + pub fn cells(&self) -> Result, TableError> { + let (rows, cols) = (self.rows, self.columns.len()); + if !(1..=MAX_ROWS).contains(&rows) { + return Err(TableError::Rows); + } + if !(1..=MAX_COLUMNS).contains(&cols) { + return Err(TableError::Columns); + } + if self.first_row > MAX_FIRST_ROW { + return Err(TableError::FirstRow); + } + let gaps = [self.row_gap, self.column_gap]; + if !self.rect.iter().all(|v| v.is_finite()) + || !gaps.iter().all(|g| g.is_finite() && *g >= 0.0) + || !self.columns.iter().all(|c| c.width.is_finite() && c.width > 0.0) + { + return Err(TableError::Numbers); + } + let r = self.rect; + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + // `rows` and `cols` are at most 100 and 50 from here on, so the counts below can't overflow + // and are exact as floats. + let row_height = (y1 - y0 - self.row_gap * (rows - 1) as f64) / rows as f64; + let room = x1 - x0 - self.column_gap * (cols - 1) as f64; + let total: f64 = self.columns.iter().map(|c| c.width).sum(); + // Corners far enough apart overflow to infinity. + if !(row_height.is_finite() && room.is_finite() && total.is_finite()) { + return Err(TableError::Numbers); + } + // Every number from here on is finite: the corners, gaps and widths are, and their sums were + // checked above. + if row_height < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + let mut spans = Vec::with_capacity(cols); + // The widths of the columns so far: each edge is worked out from the start, not from the + // last edge, so rounding doesn't add up across the table. + let mut before = 0.0; + for (c, column) in self.columns.iter().enumerate() { + let left = x0 + room * before / total + c as f64 * self.column_gap; + before += column.width; + // The last column ends at the table's edge, not where rounding puts it. + let right = if c + 1 == cols { x1 } else { x0 + room * before / total + c as f64 * self.column_gap }; + if right - left < MIN_FIELD_SIDE { + return Err(TableError::ColumnTooNarrow); + } + spans.push((left, right)); + } + let mut cells = Vec::with_capacity(rows * cols); + for row in 0..rows { + let top = y0 + row as f64 * (row_height + self.row_gap); + let bottom = if row + 1 == rows { y1 } else { top + row_height }; + if bottom - top < MIN_FIELD_SIDE { + return Err(TableError::RowsTooShort); + } + for (c, (&(left, right), column)) in spans.iter().zip(&self.columns).enumerate() { + // At most MAX_FIRST_ROW + MAX_ROWS * MAX_COLUMNS. + let seq = self.first_row + row * cols + c; + let name = cell_name(&column.pattern, self.first_row + row, c + 1, seq); + cells.push(Cell { rect: [left, top, right, bottom], name }); + } + } + Ok(cells) + } + + /// [`Self::cells`] for a form whose fields are called `existing` (full names), refusing a + /// name with a period, a name two cells share and a name the form already has, as a field + /// or as the parent of one. + pub fn cells_for<'a>(&self, existing: impl IntoIterator) -> Result, TableError> { + let cells = self.cells()?; + // "a.b" also takes "a": a new field of that name would sit beside the parent of "a.b". + let mut taken = HashSet::new(); + for name in existing { + taken.insert(name); + taken.extend(name.match_indices('.').filter_map(|(at, _)| name.get(..at))); + } + let mut seen = HashSet::with_capacity(cells.len()); + for cell in &cells { + let name = cell.name.as_str(); + if name.contains('.') { + return Err(TableError::Period(cell.name.clone())); + } + if !seen.insert(name) { + return Err(TableError::Twice(cell.name.clone())); + } + if taken.contains(name) { + return Err(TableError::Taken(cell.name.clone())); + } + } + Ok(cells) + } +} + +/// The pattern a new table's column `column` (counted from 1) starts with: `{base}_N` for the +/// first, `{base}_c2_N`, `{base}_c3_N`, … after it. +pub fn column_pattern(base: &str, column: usize) -> String { + if column <= 1 { format!("{base}_N") } else { format!("{base}_c{column}_N") } +} + +/// The first of "Table1", "Table2", … that no field in `existing` (full names) starts with, so a +/// new table's names are free. One pass over the names, however many the form has. +pub fn free_base<'a>(existing: impl IntoIterator) -> String { + // The K of every "TableK" a name starts with: "Table1", "Table1_3" and "Table1.x" take 1, and + // "Table12_1" takes 12, not 1. + let taken: HashSet = existing + .into_iter() + .filter_map(|name| { + let rest = name.strip_prefix("Table")?; + let digits = rest.get(..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()))?; + digits.parse().ok() + }) + .collect(); + // One of the first `taken.len() + 1` is always free. + let k = (1..=taken.len() as u64 + 1).find(|k| !taken.contains(k)).unwrap_or(1); + format!("Table{k}") +} + +/// What may stand on either side of an `N` that is the row number. +const SEPARATORS: [char; 4] = ['_', '-', '/', ':']; + +/// The name `pattern` gives the cell in `row` and `column` (`column` counted from 1); `seq` is the +/// cell's number counting along the rows. +/// +/// - `{row}`, `{r}`, `{n}` and `#`: the row number; `[row]`, `[r]` and `[n]`: the row number in +/// brackets (`item[N]` → `item[1]`); +/// - `{col}` and `{c}`, `[col]` and `[c]`: the column number; +/// - `{seq}` and `[seq]`: the cell number, counting along the rows from the table's first row +/// number, so a table starting at row 1 numbers its cells 1, 2, 3, …; +/// - with no row or cell number, an `N` or `n` standing alone after `_`, `-`, `/` or `:` +/// (`price_N`, `line-n-total`) is the row number, so the N in `Name` or `NET_N` stays; +/// - with no row or cell number still, the row number goes at the end. +/// +/// Braces and brackets match ignoring case. Anything else is kept as written, a period included, +/// though no field name may contain one: [`FieldTable::cells_for`] refuses those. +pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String { + let row_number = row.to_string(); + let mut out = String::with_capacity(pattern.len() + 8); + // Whether the name has a row or cell number yet. + let mut numbered = false; + let mut rest = pattern.trim(); + while let Some(at) = rest.find(['{', '[', '#']) { + // `at` is where an ASCII character starts, so both halves are whole characters. + let (before, from) = rest.split_at(at); + out.push_str(before); + if let Some(after) = from.strip_prefix('#') { + out.push_str(&row_number); + numbered = true; + rest = after; + continue; + } + let (open, close) = if from.starts_with('[') { ('[', ']') } else { ('{', '}') }; + let inside = from.get(1..).unwrap_or_default(); + let token = inside.split_once(close).and_then(|(word, after)| { + let is = |name: &str| word.eq_ignore_ascii_case(name); + if is("row") || is("r") || is("n") { + Some((row, true, after)) + } else if is("seq") { + Some((seq, true, after)) + } else if is("col") || is("c") { + Some((column, false, after)) + } else { + None + } + }); + match token { + Some((n, counts, after)) => { + if open == '[' { + out.push('['); + } + out.push_str(&n.to_string()); + if open == '[' { + out.push(']'); + } + numbered |= counts; + rest = after; + } + None => { + out.push(open); + rest = inside; + } + } + } + out.push_str(rest); + if numbered { + return out; + } + if out == "N" || out == "n" { + return row_number; + } + let chars: Vec = out.chars().collect(); + let mut named = String::with_capacity(out.len() + row_number.len()); + for (i, &ch) in chars.iter().enumerate() { + let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c)); + let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c)); + if matches!(ch, 'N' | 'n') && after_separator && before_separator { + named.push_str(&row_number); + numbered = true; + } else { + named.push(ch); + } + } + if !numbered { + named.push_str(&row_number); + } + named +} + +/// Why a table can't be added. Each is a mistake a person can make, with the numbers and names +/// apart from the words, so an interface can say it in its own language with [`TableError::said`]. +#[derive(Clone, Debug, PartialEq)] +pub enum TableError { + /// Fewer than one row, or more than [`MAX_ROWS`]. + Rows, + /// Fewer than one column, or more than [`MAX_COLUMNS`]. + Columns, + /// A first row number past [`MAX_FIRST_ROW`]. + FirstRow, + /// A corner, gap or width that is not a usable number. + Numbers, + /// The rows come out under [`MIN_FIELD_SIDE`] high. + RowsTooShort, + /// A column comes out under [`MIN_FIELD_SIDE`] wide. + ColumnTooNarrow, + /// A cell's name has a period, which would make it a child of another field. + Period(String), + /// Two cells would have this name. + Twice(String), + /// The form already has a field of this name. + Taken(String), +} + +const ROWS: &str = "a table has 1 to {max} rows"; +const COLUMNS: &str = "a table has 1 to {max} columns"; +const FIRST_ROW: &str = "the first row number can be at most {max}"; +const NUMBERS: &str = "the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0"; +const ROWS_TOO_SHORT: &str = "the rows are too short: make the area taller, or use fewer rows or less row spacing"; +const COLUMN_TOO_NARROW: &str = + "a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing"; +const PERIOD: &str = "field names can't contain a period: {name}"; +const TWICE: &str = "two cells would both be named {name}"; +const TAKEN: &str = "a field named {name} already exists"; + +/// Every sentence a [`TableError`] says, in English with its holes still in it, for an interface +/// to translate; `wordings_are_said` keeps the list in step with the errors. +pub const WORDINGS: &[&str] = &[ROWS, COLUMNS, FIRST_ROW, NUMBERS, ROWS_TOO_SHORT, COLUMN_TOO_NARROW, PERIOD, TWICE, TAKEN]; + +/// How an interface says one of our wordings in its own language, holes and all: given the English +/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English. +pub type Words<'a> = &'a dyn Fn(&str) -> String; + +/// The [`Words`] that translate nothing: the wording as it is written here. +pub fn in_english(wording: &str) -> String { + wording.to_owned() +} + +impl TableError { + /// This error as one sentence, said in the language `words` translates into. The wording is + /// translated and the numbers and names go in afterwards, in a single pass, so a name with + /// braces in it is never read as a hole. With [`in_english`] this is the error's `Display`. + pub fn said(&self, words: Words) -> String { + let limit = |wording: &str, max: usize| say(&words(wording), &[("{max}", max.to_string())]); + // A name goes in quoted, as `add_field` quotes it. + let named = |wording: &str, name: &str| say(&words(wording), &[("{name}", format!("{name:?}"))]); + match self { + TableError::Rows => limit(ROWS, MAX_ROWS), + TableError::Columns => limit(COLUMNS, MAX_COLUMNS), + TableError::FirstRow => limit(FIRST_ROW, MAX_FIRST_ROW), + TableError::Numbers => words(NUMBERS), + TableError::RowsTooShort => words(ROWS_TOO_SHORT), + TableError::ColumnTooNarrow => words(COLUMN_TOO_NARROW), + TableError::Period(name) => named(PERIOD, name), + TableError::Twice(name) => named(TWICE, name), + TableError::Taken(name) => named(TAKEN, name), + } + } +} + +impl fmt::Display for TableError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.said(&in_english)) + } +} + +impl std::error::Error for TableError {} + +/// A wording with its holes filled. A hole is a name in braces, such as `{max}`; what is not in +/// `values` is left as it is written. +fn say(wording: &str, values: &[(&str, String)]) -> String { + let mut out = String::with_capacity(wording.len() + 8); + let mut rest = wording; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + // `find` counts from the brace and `}` is one byte, so this is a char boundary. + let Some(close) = from_brace.find('}') else { + out.push_str(from_brace); + return out; + }; + let Some(hole) = from_brace.get(..=close) else { return out + from_brace }; + match values.iter().find(|(name, _)| *name == hole) { + Some((_, value)) => out.push_str(value), + None => out.push_str(hole), + } + rest = from_brace.get(close + 1..).unwrap_or_default(); + } + out.push_str(rest); + out +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + fn table(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable { + FieldTable { + rect, + rows, + columns: patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(), + row_gap: 0.0, + column_gap: 0.0, + first_row: 1, + } + } + + #[test] + fn a_row_number_goes_where_the_pattern_says() { + assert_eq!(cell_name("fieldCategory_N", 1, 1, 1), "fieldCategory_1"); + assert_eq!(cell_name("fieldCategory_N", 10, 1, 10), "fieldCategory_10"); + assert_eq!(cell_name("row_{n}", 3, 1, 3), "row_3"); + assert_eq!(cell_name("item-#", 5, 1, 5), "item-5"); + assert_eq!(cell_name("cell[N]", 2, 1, 2), "cell[2]"); + assert_eq!(cell_name("N", 4, 1, 4), "4"); + assert_eq!(cell_name("line-n-total", 6, 1, 6), "line-6-total"); + assert_eq!(cell_name("a:N/b", 7, 1, 7), "a:7/b"); + } + + #[test] + fn rows_columns_and_cells_have_their_own_tokens() { + assert_eq!(cell_name("item_{row}_{col}", 2, 3, 5), "item_2_3"); + assert_eq!(cell_name("cell_R{row}C{col}", 1, 4, 4), "cell_R1C4"); + assert_eq!(cell_name("grid[{r},{c}]", 3, 2, 8), "grid[3,2]"); + assert_eq!(cell_name("table_r{r}_c{c}", 4, 5, 20), "table_r4_c5"); + assert_eq!(cell_name("cell{seq}", 2, 2, 7), "cell7"); + assert_eq!(cell_name("cell[seq]", 2, 2, 7), "cell[7]"); + // Braces and brackets match ignoring case. + assert_eq!(cell_name("x{ROW}y{Col}z[SEQ]", 2, 3, 9), "x2y3z[9]"); + // A column number alone leaves the row to be found or added. + assert_eq!(cell_name("price{c}_N", 2, 3, 9), "price3_2"); + assert_eq!(cell_name("col{c}", 1, 2, 2), "col21"); + } + + #[test] + fn without_a_row_token_the_row_number_goes_at_the_end() { + assert_eq!(cell_name("", 1, 1, 1), "1"); + assert_eq!(cell_name(" ", 3, 1, 3), "3"); + assert_eq!(cell_name("test_", 1, 1, 1), "test_1"); + assert_eq!(cell_name("Phone_Number", 4, 1, 4), "Phone_Number4"); + // What isn't a token is kept as written. + assert_eq!(cell_name("{x}_N", 1, 1, 1), "{x}_1"); + assert_eq!(cell_name("{row", 1, 1, 1), "{row1"); + assert_eq!(cell_name("a]b[", 2, 1, 2), "a]b[2"); + // A period is kept, for `cells_for` to refuse. + assert_eq!(cell_name("test.", 1, 1, 1), "test.1"); + } + + #[test] + fn an_n_inside_a_word_is_not_the_row_number() { + assert_eq!(cell_name("Name_N", 1, 1, 1), "Name_1"); + assert_eq!(cell_name("ItemNumber_N", 2, 1, 2), "ItemNumber_2"); + assert_eq!(cell_name("NET_AMOUNT_N", 3, 1, 3), "NET_AMOUNT_3"); + assert_eq!(cell_name("CustomerName_{row}", 5, 1, 5), "CustomerName_5"); + assert_eq!(cell_name("NOTE_#", 6, 1, 6), "NOTE_6"); + assert_eq!(cell_name("BankName[N]", 7, 1, 7), "BankName[7]"); + assert_eq!(cell_name("PLAN_N", 8, 1, 8), "PLAN_8"); + assert_eq!(cell_name("line_n", 9, 1, 9), "line_9"); + assert_eq!(cell_name("Prix_é_N", 1, 1, 1), "Prix_é_1"); + assert_eq!(cell_name("名前_N", 2, 1, 2), "名前_2"); + } + + #[test] + fn a_grid_fills_its_area_with_gaps_between_the_cells() { + let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["cell_{r}_{c}", "cell_{r}_{c}"]); + t.row_gap = 10.0; + t.column_gap = 20.0; + // Width (200 - 20) / 2 = 90, height (200 - 10) / 2 = 95, rows from the top down. + let cells = t.cells().unwrap(); + let got: Vec<_> = cells.iter().map(|c| (c.rect, c.name.as_str())).collect(); + assert_eq!( + got, + [ + ([100.0, 100.0, 190.0, 195.0], "cell_1_1"), + ([210.0, 100.0, 300.0, 195.0], "cell_1_2"), + ([100.0, 205.0, 190.0, 300.0], "cell_2_1"), + ([210.0, 205.0, 300.0, 300.0], "cell_2_2"), + ] + ); + // Corners in either order are the same area. + t.rect = [300.0, 300.0, 100.0, 100.0]; + assert_eq!(t.cells().unwrap(), cells); + } + + #[test] + fn columns_share_the_width_by_their_widths() { + let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["desc_N", "price_N"]); + t.columns[0].width = 3.0; + let cells = t.cells().unwrap(); + assert_eq!(cells[0].rect, [100.0, 100.0, 250.0, 200.0]); + assert_eq!(cells[1].rect, [250.0, 100.0, 300.0, 200.0]); + assert_eq!((cells[2].name.as_str(), cells[3].name.as_str()), ("desc_2", "price_2")); + // However the shares round, the last column ends at the table's edge. + let mut t = table([0.0, 0.0, 100.0, 50.0], 1, &["a", "b", "c"]); + t.column_gap = 1.0 / 3.0; + assert_eq!(t.cells().unwrap()[2].rect[2], 100.0); + } + + #[test] + fn rows_count_from_the_first_row_and_cells_along_the_rows() { + let mut t = table([0.0, 0.0, 100.0, 100.0], 2, &["a_{seq}_{r}", "b_{seq}_{r}"]); + t.first_row = 10; + let names: Vec<_> = t.cells().unwrap().into_iter().map(|c| c.name).collect(); + assert_eq!(names, ["a_10_10", "b_11_10", "a_12_11", "b_13_11"]); + } + + #[test] + fn a_table_out_of_bounds_is_refused() { + let ok = table([0.0, 0.0, 200.0, 200.0], 4, &["a_N"]); + assert!(ok.cells().is_ok()); + let with = |f: &dyn Fn(&mut FieldTable)| { + let mut t = ok.clone(); + f(&mut t); + t.cells() + }; + assert_eq!(with(&|t| t.rows = 0), Err(TableError::Rows)); + assert_eq!(with(&|t| t.rows = MAX_ROWS + 1), Err(TableError::Rows)); + assert_eq!(with(&|t| t.columns.clear()), Err(TableError::Columns)); + // 200 points hold 50 columns of exactly 4, and 199 don't. + assert_eq!(with(&|t| t.set_columns(MAX_COLUMNS, "x")).map(|c| c.len()), Ok(4 * MAX_COLUMNS)); + assert_eq!( + with(&|t| { + t.rect[2] = 199.0; + t.set_columns(MAX_COLUMNS, "x"); + }), + Err(TableError::ColumnTooNarrow) + ); + assert_eq!(with(&|t| t.columns = vec![t.columns[0].clone(); MAX_COLUMNS + 1]), Err(TableError::Columns)); + assert_eq!(with(&|t| t.first_row = MAX_FIRST_ROW + 1), Err(TableError::FirstRow)); + assert_eq!(with(&|t| t.rect[0] = f64::NAN), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.rect = [-f64::MAX, 0.0, f64::MAX, 200.0]), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.row_gap = -1.0), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.column_gap = f64::INFINITY), Err(TableError::Numbers)); + assert_eq!(with(&|t| t.columns[0].width = 0.0), Err(TableError::Numbers)); + // 200 points hold 50 rows of exactly 4, not 51. + assert_eq!(with(&|t| t.rows = 50).map(|c| c.len()), Ok(50)); + assert_eq!(with(&|t| t.rows = 51), Err(TableError::RowsTooShort)); + assert_eq!(with(&|t| t.row_gap = 70.0), Err(TableError::RowsTooShort)); + assert_eq!(with(&|t| t.rect = [0.0, 0.0, 3.0, 200.0]), Err(TableError::ColumnTooNarrow)); + // One column squeezed under 4 points by the others' widths. + assert_eq!( + with(&|t| t.columns = vec![TableColumn { pattern: "a_N".into(), width: 1.0 }, TableColumn { pattern: "b_N".into(), width: 99.0 }]), + Err(TableError::ColumnTooNarrow) + ); + } + + #[test] + fn names_a_form_would_refuse_are_refused_before_anything_is_added() { + let t = table([0.0, 0.0, 200.0, 200.0], 2, &["a_N", "b_N"]); + assert!(t.cells_for(["c_1", "a_10"]).is_ok()); + assert_eq!(t.cells_for(["x", "b_2"]), Err(TableError::Taken("b_2".into()))); + // The parent of an existing field is taken too. + assert_eq!(t.cells_for(["a_1.street"]), Err(TableError::Taken("a_1".into()))); + let same = table([0.0, 0.0, 200.0, 200.0], 2, &["item_N", "item_N"]); + assert_eq!(same.cells_for([]), Err(TableError::Twice("item_1".into()))); + let dotted = table([0.0, 0.0, 200.0, 200.0], 2, &["row.{n}"]); + assert_eq!(dotted.cells_for([]), Err(TableError::Period("row.1".into()))); + } + + #[test] + fn a_new_table_takes_the_first_free_base() { + assert_eq!(free_base([]), "Table1"); + assert_eq!(free_base(["Table1_1", "Table1_c2_4"]), "Table2"); + assert_eq!(free_base(["Table1", "Table2.x", "Table3_N"]), "Table4"); + // "Table12_1" belongs to Table12, not Table1. + assert_eq!(free_base(["Table12_1", "Tables"]), "Table1"); + // As fast for a form with thousands of fields: one pass, not one per candidate. + let many: Vec = (1..=20_000).map(|k| format!("Table{k}_1")).collect(); + assert_eq!(free_base(many.iter().map(String::as_str)), "Table20001"); + let t = FieldTable::new([0.0, 0.0, 200.0, 200.0], &free_base(["Table1_1"])); + let names: Vec<_> = t.cells_for(["Table1_1"]).unwrap().into_iter().map(|c| c.name).collect(); + assert_eq!(names, ["Table2_1", "Table2_2", "Table2_3", "Table2_4"]); + } + + #[test] + fn added_columns_keep_the_widths_already_set() { + let mut t = FieldTable::new([0.0, 0.0, 400.0, 100.0], "T"); + t.columns[0].width = 2.0; + t.set_columns(3, "T"); + let got: Vec<_> = t.columns.iter().map(|c| (c.pattern.as_str(), c.width)).collect(); + assert_eq!(got, [("T_N", 2.0), ("T_c2_N", 2.0), ("T_c3_N", 2.0)]); + t.columns[2].width = 4.0; + t.set_columns(4, "T"); + assert_eq!(t.columns[3].width, 8.0 / 3.0); + t.set_columns(2, "T"); + assert_eq!(t.columns.len(), 2); + // Held to the bounds. + t.set_columns(0, "T"); + assert_eq!(t.columns.len(), 1); + t.set_columns(usize::MAX, "T"); + assert_eq!(t.columns.len(), MAX_COLUMNS); + } + + #[test] + fn wordings_are_said() { + let mistakes = [ + TableError::Rows, + TableError::Columns, + TableError::FirstRow, + TableError::Numbers, + TableError::RowsTooShort, + TableError::ColumnTooNarrow, + TableError::Period("a.1".into()), + TableError::Twice("a_1".into()), + TableError::Taken("a_1".into()), + ]; + assert_eq!(WORDINGS.len(), mistakes.len(), "one wording per mistake"); + // Each mistake says one of the wordings: translated to a marker, only the values remain. + for e in &mistakes { + let marked = e.said(&|w| format!("<{}>", WORDINGS.iter().position(|x| *x == w).unwrap_or(usize::MAX))); + assert!(!marked.contains(&usize::MAX.to_string()), "{e:?} says a wording that isn't listed"); + assert_eq!(e.said(&in_english), e.to_string()); + } + assert_eq!(TableError::Rows.to_string(), "a table has 1 to 100 rows"); + assert_eq!(TableError::Taken("a_1".into()).to_string(), "a field named \"a_1\" already exists"); + // A name goes in as it stands, braces and all, and is never read as a hole. + let other = |w: &str| if w == TAKEN { "{name} existe ya".to_owned() } else { w.to_owned() }; + assert_eq!(TableError::Taken("{max}".into()).said(&other), "\"{max}\" existe ya"); + } + + proptest! { + /// Any pattern names every cell, and never panics. + #[test] + fn any_pattern_gives_a_name(pattern in "(\\PC|[{}\\[\\]#Nn_:/.-]){0,24}", row in 0usize..200_000, column in 1usize..60, seq in 0usize..300_000) { + let name = cell_name(&pattern, row, column, seq); + prop_assert!(!name.is_empty()); + prop_assert_eq!(name.trim(), name.as_str()); + } + + /// Any table either lays out cells of at least the smallest side inside its area, or says + /// why not. + #[test] + fn any_table_lays_out_or_says_why( + rect in prop::array::uniform4(-2000.0f64..2000.0), + rows in 0usize..120, + widths in prop::collection::vec(0.01f64..100.0, 0..60), + row_gap in -1.0f64..50.0, + column_gap in -1.0f64..50.0, + ) { + let t = FieldTable { + rect, + rows, + columns: widths.iter().map(|w| TableColumn { pattern: "a{seq}".into(), width: *w }).collect(), + row_gap, + column_gap, + first_row: 1, + }; + if let Ok(cells) = t.cells_for([]) { + prop_assert_eq!(cells.len(), rows * widths.len()); + let (x0, y0, x1, y1) = (rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])); + for c in &cells { + let [l, t, r, b] = c.rect; + prop_assert!(r - l >= MIN_FIELD_SIDE && b - t >= MIN_FIELD_SIDE, "{:?}", c); + prop_assert!(l >= x0 - 1e-6 && r <= x1 + 1e-6 && t >= y0 - 1e-6 && b <= y1 + 1e-6, "{:?}", c); + } + } + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3d78f952d6..bbce17eee2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2810,6 +2810,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut open_props: Option<(usize, usize)> = None; let mut field_props = false; let mut field_placed = false; + let mut open_table: Option<(usize, [f64; 4])> = None; + // The table being set up in its window, shown on its own document's page. + let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); let can_modify = doc.allows_modification(); if preparing { crate::prepare::after_refresh(view, &form); @@ -3152,9 +3155,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { QuickTool::Field(f) => Some(f), _ => None, }; - let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, can_modify, view); + // While a table is being set up, nothing else is drawn or moved. + let allowed = can_modify && table_preview.is_none() && open_table.is_none(); + let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view); field_props |= o.properties; field_placed |= o.placed; + open_table = open_table.or(o.table); (o.consumed || field_tool.is_some(), false) } else { use crate::forms_ui::FieldPointer; @@ -3467,6 +3473,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if preparing { crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } 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(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) { @@ -4080,6 +4089,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { app.boxes_draft.seeded = None; app.dialog = Some(crate::Dialog::PageBoxes); } + if let Some((page, rect)) = open_table { + app.field_table_draft = Some(crate::field_table_ui::FieldTableDraft::new(app.views[index].id, page, rect, &form, generation)); + app.dialog = Some(crate::Dialog::FieldTable); + // The window lasts while the form is prepared. Drawn with the tool picked but its panel + // closed, the tool goes back to Select above, so the panel opens to keep it prepared. + app.left = crate::LeftPanel::Tool("form"); + app.reveal_tools(); + } if let Some(n) = form_notice { match n { crate::forms_ui::FormNotice::Security => app.notify_tr("The document's security settings don't allow filling in form fields"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8bff5ca8d7..64863f86e5 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -25,6 +25,14 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if app.dialog != Some(Dialog::WordCount) { app.word_count_draft.cancel(); } + if app.dialog != Some(Dialog::FieldTable) { + app.field_table_draft = None; + } + // A movable window, not this modal: the page under it shows the table as it changes. + if app.dialog == Some(Dialog::FieldTable) { + crate::field_table_ui::show(app, ctx); + return; + } let Some(dialog) = app.dialog else { app.props_draft = None; app.view_draft = None; @@ -719,6 +727,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); return; } + // Never reached: `dialogs` shows this movable window above and returns before the modal. + // The arm keeps the match exhaustive. + Dialog::FieldTable => close = true, Dialog::Optimize => { let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); optimize_now = ok; diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs new file mode 100644 index 0000000000..9314dc927d --- /dev/null +++ b/crates/ui-egui/src/field_table_ui.rs @@ -0,0 +1,512 @@ +//! Prepare a form ▸ Text field table: a movable window that divides the area drawn on a page into +//! rows and columns of text fields, while the page shows the table as it changes. The table is +//! the engine's [`FieldTable`]; this is its window. +//! +//! The window is not modal, so that the page stays in view, but it belongs to one page of one +//! document as it was when the window opened: it closes when that document stops being the one +//! prepared or changes in any way (a page rotated, moved or deleted, an edit undone), and adds the +//! table there. + +use std::ops::RangeInclusive; +use std::sync::Arc; + +use egui::{Align, Color32, CornerRadius, Layout, Rect, RichText, Sense, Stroke, pos2, vec2}; +use pdfcraft_engine::field_table::{self, Cell, FieldTable, MAX_COLUMNS, MAX_FIRST_ROW, MAX_ROWS, TableError}; +use pdfcraft_engine::{DocId, Edit, FormField}; +use pdfcraft_render::PageInfo; + +use crate::canvas::PageXform; +use crate::{Dialog, PdfCraftApp, i18n, theme, widgets}; + +const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +const ERROR_RED: Color32 = Color32::from_rgb(0xD1, 0x3B, 0x3B); +/// A divider can't be dragged to leave a column less than this share of the table, or less than a +/// quarter of what the two columns it divides have between them when that is smaller. +const MIN_SHARE: f64 = 0.04; + +/// The table being set up in the window, and where it goes. +#[derive(Clone, Debug)] +pub struct FieldTableDraft { + /// The document the table goes in. + pub doc: DocId, + /// The page it goes on, 0-based. + pub page: usize, + pub table: FieldTable, + /// What added columns are named after: the first free "TableK" when the window opened. + base: String, + /// The document's edit generation when the window opened: any edit since closes it. + generation: u64, + /// The table as last laid out, kept until the table changes. + laid: Option>, + /// A field in the window had the keyboard last frame, so Escape leaves the field. + typing: bool, +} + +/// Two drafts are the same table in the same place; what is kept for the next frame isn't part of it. +impl PartialEq for FieldTableDraft { + fn eq(&self, other: &Self) -> bool { + (self.doc, self.page, &self.table, &self.base, self.generation) == (other.doc, other.page, &other.table, &other.base, other.generation) + } +} + +/// A table laid out and checked once, for every frame it stays the same: the cells the page and the +/// window show, and the edit Add fields applies. Up to 100 × 50 cells, so not every frame. +#[derive(Debug)] +pub(crate) struct Laid { + table: FieldTable, + cells: Result, TableError>, + edit: Result, +} + +/// What the page shows of the table being set up: its cells, or why it can't be laid out. +pub(crate) struct Preview { + page: usize, + laid: Arc, +} + +/// How the window was left this frame. +enum Choice { + Add, + Cancel, +} + +impl FieldTableDraft { + /// A table filling `rect` (view space) on `page` of a document at edit `generation`, named after + /// the first base `form` leaves free. + pub(crate) fn new(doc: DocId, page: usize, rect: [f64; 4], form: &[FormField], generation: u64) -> Self { + let base = field_table::free_base(form.iter().map(|f| f.name.as_str())); + FieldTableDraft { doc, page, table: FieldTable::new(rect, &base), base, generation, laid: None, typing: false } + } + + /// The table laid out on its page (`info`, in a form whose fields are `form`), worked out again + /// only when the table has changed since. The form can't change under it: an edit closes the + /// window. + pub(crate) fn laid(&mut self, info: &PageInfo, form: &[FormField]) -> Arc { + if let Some(laid) = self.laid.as_ref().filter(|l| l.table == self.table) { + return Arc::clone(laid); + } + let laid = + Arc::new(Laid { table: self.table.clone(), cells: self.table.cells(), edit: field_table::edit(&self.table, self.page, info, form) }); + self.laid = Some(Arc::clone(&laid)); + laid + } + + /// What the page shows of this table this frame. + pub(crate) fn preview(&mut self, info: &PageInfo, form: &[FormField]) -> Preview { + Preview { page: self.page, laid: self.laid(info, form) } + } + + /// Each column's share of the table's width, together 1. + fn shares(&self) -> Vec { + let total: f64 = self.table.columns.iter().map(|c| c.width).sum(); + let n = self.table.columns.len().max(1) as f64; + self.table.columns.iter().map(|c| if total > 0.0 && total.is_finite() { c.width / total } else { 1.0 / n }).collect() + } + + /// Move the divider after column `i` (0-based) by `delta`, a share of the table's width. The + /// two columns it divides keep [`MIN_SHARE`] each, or a quarter of what they have between them + /// when that is less, so narrow columns can still be resized; the other columns keep theirs. + fn move_divider(&mut self, i: usize, delta: f64) { + let total: f64 = self.table.columns.iter().map(|c| c.width).sum(); + let (Some(a), Some(b)) = (self.table.columns.get(i).map(|c| c.width), self.table.columns.get(i + 1).map(|c| c.width)) else { return }; + if !(total > 0.0 && total.is_finite() && delta.is_finite()) { + return; + } + let pair = (a + b) / total; + // At most half the pair, so the bounds never cross; `max` then `min` all the same, never + // `clamp`, which panics if they do. + let least = MIN_SHARE.min(pair / 4.0); + let left = (a / total + delta).max(least).min(pair - least); + if let Some(c) = self.table.columns.get_mut(i) { + c.width = left * total; + } + if let Some(c) = self.table.columns.get_mut(i + 1) { + c.width = (pair - left) * total; + } + } + + /// Give every column the same width: their widths are shares, so the same number does it. + fn distribute_evenly(&mut self) { + for c in &mut self.table.columns { + c.width = 1.0; + } + } +} + +/// Show the window while [`Dialog::FieldTable`] is open, and add the table when asked to. +pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { + // The window belongs to one page of one document, while that document is being prepared. + let active = app.active_ids().map(|(_, id)| id); + let preparing = app.is_preparing(); + let belongs = app.field_table_draft.as_ref().is_some_and(|d| { + active == Some(d.doc) + && preparing + && app + .session + .get(d.doc) + .is_some_and(|doc| doc.edit_generation() == d.generation && d.page < doc.info.pages.len() && doc.allows_modification()) + }); + if !belongs { + close(app); + return; + } + // Both there, as `belongs` says; the fields are borrowed apart, so the window can change the + // table while it reads the document. + let Some(draft) = app.field_table_draft.as_mut() else { return }; + let Some((info, form)) = app.session.get(draft.doc).and_then(|doc| Some((doc.info.pages.get(draft.page)?, &doc.form))) else { return }; + let t = theme::Tokens::get(ctx); + let mut open = true; + let mut choice = None; + let mut laid = None; + egui::Window::new(tl!("Text Field Table")) + .id(egui::Id::new("field_table_window")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .order(egui::Order::Foreground) + .min_width(440.0) + .max_width(440.0) + .default_pos(egui::pos2(120.0, 100.0)) + .show(ctx, |ui| { + widgets::begin_dialog(ui); + let (c, l) = body(ui, draft, info, form, &t); + choice = c; + laid = Some(l); + }); + // Escape closes it, as it closes a modal dialog, unless a field had the keyboard: then Escape + // only leaves the field, and what was set up stays. Enter is left to the field being typed in. + let escape = !draft.typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + draft.typing = ctx.egui_wants_keyboard_input(); + match choice { + _ if !open || escape => close(app), + Some(Choice::Cancel) => close(app), + // Added, the window closes; refused, it stays as it is, with the notice saying why. + Some(Choice::Add) => { + if let Some(Ok(edit)) = laid.as_deref().map(|l| l.edit.clone()) + && app.apply_edit(edit) + { + close(app); + } + } + None => {} + } +} + +/// Drop the table being set up, and the dialog with it if it is still the open one. +fn close(app: &mut PdfCraftApp) { + app.field_table_draft = None; + if app.dialog == Some(Dialog::FieldTable) { + app.dialog = None; + } +} + +/// The window's controls, the names the table will have, and why it can't be added if it can't. +/// Returns what was chosen and the table as it now stands, laid out and checked. +fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[FormField], t: &theme::Tokens) -> (Option, Arc) { + let columns = d.table.columns.len(); + egui::Grid::new("field_table_grid").num_columns(2).spacing([14.0, 8.0]).min_col_width(120.0).show(ui, |ui| { + ui.label(tl!("Rows")); + stepper(ui, &mut d.table.rows, 1..=MAX_ROWS, 1.0, ""); + ui.end_row(); + ui.label(tl!("Columns")); + let mut n = columns; + if stepper(ui, &mut n, 1..=MAX_COLUMNS, 1.0, "") { + d.table.set_columns(n, &d.base); + } + ui.end_row(); + ui.label(tl!("Row spacing")); + stepper(ui, &mut d.table.row_gap, 0.0..=100.0, 1.0, " pt"); + ui.end_row(); + ui.label(tl!("Column spacing")); + ui.add_enabled_ui(columns > 1, |ui| stepper(ui, &mut d.table.column_gap, 0.0..=100.0, 1.0, " pt")); + ui.end_row(); + ui.label(tl!("First row number")); + ui.add(egui::DragValue::new(&mut d.table.first_row).range(0..=MAX_FIRST_ROW)); + ui.end_row(); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(RichText::new(tl!("Column widths")).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.add_enabled_ui(d.table.columns.len() > 1, |ui| { + if widgets::pill_button(ui, tl!("Distribute evenly"), false).clicked() { + d.distribute_evenly(); + } + }); + }); + }); + ui.add_space(4.0); + column_bar(ui, d, t); + + ui.add_space(8.0); + ui.label(RichText::new(tl!("Field names")).strong()); + // How the names are worked out. Its tokens are the same in every language. + let hint = tl!("N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last."); + ui.add(egui::Label::new(RichText::new(hint).color(t.text_muted).size(11.0)).wrap()); + ui.add_space(4.0); + // The widths shown don't depend on the names being typed below. + let layout = d.laid(info, form); + let shares = d.shares(); + let scrolls = d.table.columns.len() > 3; + let mut column_fields = |ui: &mut egui::Ui| { + for (c, column) in d.table.columns.iter_mut().enumerate() { + let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]); + let share = shares.get(c).copied().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(RichText::new(column_label(c)).strong()); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0)); + ui.label(RichText::new(size).color(t.text_muted).size(11.0)); + }); + }); + let full = ui.available_width(); + ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + ui.add_space(4.0); + } + }; + if scrolls { + egui::ScrollArea::vertical().max_height(180.0).show(ui, column_fields); + } else { + column_fields(ui); + } + + ui.add_space(8.0); + // The names, and the edit Add fields applies, from the table as it now stands. + let laid = d.laid(info, form); + if let Ok(cells) = &laid.cells { + let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect(); + let shown = match names.as_slice() { + [] => String::new(), + [one] => (*one).to_owned(), + [first, second] => format!("{first}, {second}"), + [first, second, .., last] => format!("{first}, {second} … {last}"), + }; + ui.horizontal(|ui| { + ui.label(RichText::new(i18n::trn(i18n::current(), names.len() as u64, "{n} field", "{n} fields")).color(t.text_muted)); + ui.add(egui::Label::new(RichText::new(shown).font(theme::medium(12.0))).truncate()); + }); + } + if let Err(e) = &laid.edit { + ui.add(egui::Label::new(RichText::new(e.said(&i18n::words)).color(ERROR_RED).size(11.0)).wrap()); + } + + ui.add_space(14.0); + let mut choice = None; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(laid.edit.is_ok(), |ui| widgets::pill_button(ui, tl!("Add fields"), true)).inner.clicked() { + choice = Some(Choice::Add); + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + choice = Some(Choice::Cancel); + } + }); + (choice, laid) +} + +/// "Column 2" for the column at 0-based `index`. +fn column_label(index: usize) -> String { + i18n::fmt(tl!("Column {n}"), &[("n", &index.saturating_add(1).to_string())]) +} + +/// A number with minus and plus buttons either side, held to `range`, moving by `step`. Whether it +/// changed. +fn stepper(ui: &mut egui::Ui, value: &mut N, range: RangeInclusive, step: f64, suffix: &str) -> bool { + let before = *value; + let (low, high) = (range.start().to_f64(), range.end().to_f64()); + ui.horizontal(|ui| { + if ui.button("-").clicked() { + *value = N::from_f64((value.to_f64() - step).max(low)); + } + ui.add(egui::DragValue::new(value).range(range).speed(step / 2.0).suffix(suffix)); + if ui.button("+").clicked() { + *value = N::from_f64((value.to_f64() + step).min(high)); + } + }); + *value != before +} + +/// The columns as a bar with a divider between each two, dragged (or moved with the arrow keys once +/// focused) to share the width differently. +fn column_bar(ui: &mut egui::Ui, d: &mut FieldTableDraft, t: &theme::Tokens) { + let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width().max(100.0), 28.0), Sense::hover()); + let edges = |shares: &[f64]| -> Vec { + let mut x = bar.left(); + shares + .iter() + .map(|s| { + x += *s as f32 * bar.width(); + x + }) + .collect() + }; + let n = d.table.columns.len(); + // Wide enough to grab, never reaching past the middle of a column. + let reach = (bar.width() / n.max(1) as f32 / 2.0).clamp(2.0, 8.0); + let mut active = None; + let ends = edges(&d.shares()); + for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) { + let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom())); + let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag()); + let share = d.shares().get(i).copied().unwrap_or_default(); + resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i))); + let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width()); + if resp.has_focus() { + ui.input_mut(|input| { + if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft) { + delta -= 0.01; + } + if input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight) { + delta += 0.01; + } + }); + } + if delta != 0.0 { + d.move_divider(i, delta); + } + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + if resp.hovered() || resp.dragged() || resp.has_focus() { + active = Some(i); + } + } + + let painter = ui.painter_at(bar); + let shares = d.shares(); + let mut left = bar.left(); + for (c, (&right, share)) in edges(&shares).iter().zip(&shares).enumerate() { + // The last column ends at the bar's edge, whatever rounding says. + let right = if c + 1 == n { bar.right() } else { right.min(bar.right()) }; + let column = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); + painter.rect_filled(column, CornerRadius::ZERO, SELECT_BLUE.gamma_multiply(if c % 2 == 0 { 0.11 } else { 0.055 })); + if column.width() > 32.0 { + painter.text(column.center(), egui::Align2::CENTER_CENTER, format!("{:.0}%", share * 100.0), theme::medium(11.0), t.text); + } + left = right; + } + painter.rect_stroke(bar, CornerRadius::same(3), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + for (i, &x) in edges(&shares).iter().enumerate().take(n.saturating_sub(1)) { + let stroke = if active == Some(i) { Stroke::new(3.0, SELECT_BLUE) } else { Stroke::new(1.5, t.border) }; + painter.line_segment([pos2(x, bar.top() + 1.0), pos2(x, bar.bottom() - 1.0)], stroke); + } +} + +/// The table being set up, on its page: each cell with its name, or the table's outline in red +/// with the reason it can't be laid out. Only the cells in view are drawn, and only those with room +/// for it get their name: a table can have 5,000 cells. +pub(crate) fn paint_preview(painter: &egui::Painter, xf: &PageXform, page: usize, preview: &Preview) { + if preview.page != page { + return; + } + let zoom = (xf.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0); + let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); + let on_screen = |r: [f64; 4]| xf.view_rect([r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); + let outline = on_screen(preview.laid.table.rect); + let visible = painter.clip_rect(); + // A tag of white `text` in the middle of `area`, kept inside it. + let tag = |area: Rect, text: String, fill: Color32| { + let galley = painter.layout_no_wrap(text, font.clone(), Color32::WHITE); + let tag = Rect::from_center_size(area.center(), galley.size() + vec2(6.0, 2.0)); + let inside = painter.with_clip_rect(painter.clip_rect().intersect(area)); + inside.rect_filled(tag, CornerRadius::same(1), fill); + inside.galley(tag.min + vec2(3.0, 1.0), galley, Color32::WHITE); + }; + match &preview.laid.cells { + Ok(cells) => { + painter.rect_stroke(outline, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.3)), egui::StrokeKind::Outside); + for cell in cells { + let r = on_screen(cell.rect); + if !visible.intersects(r) { + continue; + } + painter.rect_filled(r, CornerRadius::ZERO, crate::prepare::FIELD_FILL); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Inside); + if r.height() >= font.size + 2.0 && r.width() >= 24.0 { + tag(r, cell.name.clone(), SELECT_BLUE); + } + } + } + Err(e) => { + painter.rect_stroke(outline, CornerRadius::ZERO, Stroke::new(1.5, ERROR_RED), egui::StrokeKind::Inside); + tag(outline, e.said(&i18n::words), ERROR_RED); + } + } +} + +#[cfg(test)] +mod tests { + use pdfcraft_engine::field_table::TableColumn; + + use super::*; + + fn draft(columns: usize) -> FieldTableDraft { + let mut d = FieldTableDraft::new(DocId(1), 0, [0.0, 0.0, 400.0, 200.0], &[], 0); + d.table.set_columns(columns, &d.base); + d + } + + /// Thirty even columns leave each pair less than twice the least share a divider keeps: the + /// two bounds crossed, and `f64::clamp` panicked on the first drag. + #[test] + fn dragging_a_divider_between_narrow_columns_never_panics() { + for columns in [2, 25, 26, 30, MAX_COLUMNS] { + for delta in [-1.0, -0.01, 0.0, 0.01, 1.0, f64::NAN, f64::INFINITY] { + let mut d = draft(columns); + let before: f64 = d.table.columns.iter().map(|c| c.width).sum(); + d.move_divider(0, delta); + d.move_divider(columns - 2, delta); + let after: f64 = d.table.columns.iter().map(|c| c.width).sum(); + assert!((after - before).abs() < 1e-9, "{columns} columns, {delta}: the total width stays"); + assert!(d.table.columns.iter().all(|c| c.width > 0.0), "{columns} columns, {delta}: no column vanishes"); + } + // And the divider still moves. + let mut d = draft(columns); + d.move_divider(0, -0.01); + assert!(d.table.columns[0].width < d.table.columns[1].width, "{columns} columns: {:?}", d.shares()); + } + } + + #[test] + fn a_divider_moves_only_the_two_columns_it_divides() { + let mut d = draft(3); + d.move_divider(0, 0.1); + let shares = d.shares(); + assert!((shares[0] - (1.0 / 3.0 + 0.1)).abs() < 1e-9 && (shares[1] - (1.0 / 3.0 - 0.1)).abs() < 1e-9, "{shares:?}"); + assert!((shares[2] - 1.0 / 3.0).abs() < 1e-9); + // Dragged past its neighbour, a column stops at the least share; past the end, nothing. + d.move_divider(0, 1.0); + assert!((d.shares()[1] - MIN_SHARE).abs() < 1e-9); + let before = d.clone(); + d.move_divider(2, 0.5); + assert_eq!(d, before); + d.distribute_evenly(); + assert!(d.table.columns.iter().all(|c| c.width == 1.0)); + } + + /// The window and the page share one layout of the table, worked out again only when the table + /// changes: at 100 × 50 cells, laying it out several times a frame was 25,000 names a frame. + #[test] + fn the_layout_is_worked_out_again_only_when_the_table_changes() { + let info = PageInfo { width: 400.0, height: 300.0, label: "1".into(), crop: [0.0, 0.0, 400.0, 300.0], rotation: 0 }; + let mut d = draft(2); + let first = d.laid(&info, &[]); + assert!(Arc::ptr_eq(&first, &d.laid(&info, &[])), "the same table, the same layout"); + assert!(Arc::ptr_eq(&first, &d.preview(&info, &[]).laid), "the page shares it"); + d.table.rows = 5; + let second = d.laid(&info, &[]); + assert!(!Arc::ptr_eq(&first, &second), "a changed table is laid out again"); + assert_eq!(second.cells.as_ref().map(Vec::len), Ok(10)); + assert!(second.edit.is_ok()); + } + + #[test] + fn added_columns_are_named_after_the_tables_base() { + let mut d = FieldTableDraft::new(DocId(1), 0, [0.0, 0.0, 400.0, 200.0], &[], 0); + d.table.set_columns(2, &d.base); + let patterns: Vec<_> = d.table.columns.iter().map(|c: &TableColumn| c.pattern.as_str()).collect(); + assert_eq!(patterns, ["Table1_N", "Table1_c2_N"]); + } +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index f6b3d093d5..41c62f3744 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2757,3 +2757,23 @@ certificate Version الإصدار Paragraph spacing تباعد الفقرات Less أقل Other options خيارات أخرى + +# Prepare a form ▸ Text field table + Add text field table إضافة جدول حقول نصية + Text field table جدول حقول نصية + Text Field Table جدول حقول نصية + Columns الأعمدة + Row spacing المسافة بين الصفوف + Column spacing المسافة بين الأعمدة + First row number رقم الصف الأول + Column widths عروض الأعمدة + Distribute evenly توزيع بالتساوي + Field names أسماء الحقول + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N أو {row} هو رقم الصف، و{col} رقم العمود، و{seq} رقم الخلية. إذا لم يوجد أي منها، يُضاف رقم الصف في النهاية. + Column {n} العمود {n} + Add fields إضافة الحقول + the rows are too short: make the area taller, or use fewer rows or less row spacing الصفوف قصيرة جدًا: اجعل المنطقة أطول، أو استخدم صفوفًا أقل أو مسافة أقل بين الصفوف + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing أحد الأعمدة ضيق جدًا: اجعل المنطقة أعرض، أو امنح العمود عرضًا أكبر، أو استخدم أعمدة أقل أو مسافة أقل بين الأعمدة + field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} + two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} + a field named {name} already exists يوجد حقل باسم {name} بالفعل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 90e152c27b..7d5b84cf42 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2444,3 +2444,23 @@ animation effects Off Изключени Paragraph spacing Разстояние между абзаците Less По-малко Other options Други опции + +# Prepare a form ▸ Text field table + Add text field table Добави таблица от текстови полета + Text field table Таблица от текстови полета + Text Field Table Таблица от текстови полета + Columns Колони + Row spacing Разстояние между редовете + Column spacing Разстояние между колоните + First row number Номер на първия ред + Column widths Ширини на колоните + Distribute evenly Разпредели равномерно + Field names Имена на полетата + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N или {row} е номерът на реда, {col} – на колоната, {seq} – на клетката. Без тях номерът на реда се добавя накрая. + Column {n} Колона {n} + Add fields Добави полета + the rows are too short: make the area taller, or use fewer rows or less row spacing редовете са твърде ниски: направете областта по-висока или използвайте по-малко редове или по-малко разстояние между тях + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing колона е твърде тясна: направете областта по-широка, дайте ѝ повече място или използвайте по-малко колони или по-малко разстояние между тях + field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} + two cells would both be named {name} две клетки биха имали едно и също име {name} + a field named {name} already exists вече съществува поле с име {name} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1366cb2546..e5c32b2693 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2555,3 +2555,23 @@ animation effects Off Vypnuto Paragraph spacing Mezera mezi odstavci Less Méně Other options Další možnosti + +# Prepare a form ▸ Text field table + Add text field table Přidat tabulku textových polí + Text field table Tabulka textových polí + Text Field Table Tabulka textových polí + Columns Sloupce + Row spacing Mezera mezi řádky + Column spacing Mezera mezi sloupci + First row number Číslo prvního řádku + Column widths Šířky sloupců + Distribute evenly Rozložit rovnoměrně + Field names Názvy polí + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N nebo {row} je číslo řádku, {col} číslo sloupce a {seq} číslo buňky. Bez nich se číslo řádku přidá na konec. + Column {n} Sloupec {n} + Add fields Přidat pole + the rows are too short: make the area taller, or use fewer rows or less row spacing řádky jsou příliš nízké: zvětšete výšku oblasti nebo použijte méně řádků či menší mezeru mezi řádky + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing sloupec je příliš úzký: rozšiřte oblast, dejte sloupci více místa nebo použijte méně sloupců či menší mezeru mezi sloupci + field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} + two cells would both be named {name} dvě buňky by se jmenovaly {name} + a field named {name} already exists pole s názvem {name} už existuje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ac136cd91a..586cd001e1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2482,3 +2482,23 @@ animation effects Off Aus Paragraph spacing Absatzabstand Less Weniger Other options Weitere Optionen + +# Prepare a form ▸ Text field table + Add text field table Textfeldtabelle hinzufügen + Text field table Textfeldtabelle + Text Field Table Textfeldtabelle + Columns Spalten + Row spacing Zeilenabstand + Column spacing Spaltenabstand + First row number Erste Zeilennummer + Column widths Spaltenbreiten + Distribute evenly Gleichmäßig verteilen + Field names Feldnamen + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N oder {row} ist die Zeilennummer, {col} die Spaltennummer, {seq} die Zellennummer. Ohne eines davon steht die Zeilennummer am Ende. + Column {n} Spalte {n} + Add fields Felder hinzufügen + the rows are too short: make the area taller, or use fewer rows or less row spacing die Zeilen sind zu niedrig: Mach den Bereich höher oder verwende weniger Zeilen oder weniger Zeilenabstand + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing eine Spalte ist zu schmal: Mach den Bereich breiter, gib der Spalte mehr Breite oder verwende weniger Spalten oder weniger Spaltenabstand + field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} + two cells would both be named {name} zwei Zellen hätten beide den Namen {name} + a field named {name} already exists ein Feld namens {name} ist bereits vorhanden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 133e8ca8e6..72e79d4aae 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2487,3 +2487,23 @@ animation effects Off Ανενεργά Paragraph spacing Απόσταση παραγράφων Less Λιγότερα Other options Άλλες επιλογές + +# Prepare a form ▸ Text field table + Add text field table Προσθήκη πίνακα πεδίων κειμένου + Text field table Πίνακας πεδίων κειμένου + Text Field Table Πίνακας πεδίων κειμένου + Columns Στήλες + Row spacing Απόσταση γραμμών + Column spacing Απόσταση στηλών + First row number Αριθμός πρώτης γραμμής + Column widths Πλάτη στηλών + Distribute evenly Ομοιόμορφη κατανομή + Field names Ονόματα πεδίων + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. Το N ή το {row} είναι ο αριθμός γραμμής, το {col} ο αριθμός στήλης και το {seq} ο αριθμός κελιού. Χωρίς κανένα από αυτά, ο αριθμός γραμμής μπαίνει στο τέλος. + Column {n} Στήλη {n} + Add fields Προσθήκη πεδίων + the rows are too short: make the area taller, or use fewer rows or less row spacing οι γραμμές είναι πολύ χαμηλές: κάντε την περιοχή ψηλότερη ή χρησιμοποιήστε λιγότερες γραμμές ή μικρότερη απόσταση γραμμών + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing μια στήλη είναι πολύ στενή: κάντε την περιοχή φαρδύτερη, δώστε στη στήλη περισσότερο πλάτος ή χρησιμοποιήστε λιγότερες στήλες ή μικρότερη απόσταση στηλών + field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} + two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} + a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7c7b7c8d71..a8056676c6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2621,3 +2621,27 @@ animation effects Off Desactivados # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. + +# Prepare a form ▸ Text field table + Add text field table Añadir tabla de campos de texto + Text field table Tabla de campos de texto + Text Field Table Tabla de campos de texto + Columns Columnas + Row spacing Espacio entre filas + Column spacing Espacio entre columnas + First row number Número de la primera fila + Column widths Anchos de columna + Distribute evenly Repartir por igual + Field names Nombres de los campos + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N o {row} es el número de fila, {col} el de columna y {seq} el de celda. Si no hay ninguno, el número de fila va al final. + Column {n} Columna {n} + Add fields Añadir campos + a table has 1 to {max} rows una tabla tiene de 1 a {max} filas + a table has 1 to {max} columns una tabla tiene de 1 a {max} columnas + the first row number can be at most {max} el número de la primera fila puede ser como mucho {max} + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 el área, el espaciado y los anchos de columna deben ser números, el espaciado 0 o más y cada ancho mayor que 0 + the rows are too short: make the area taller, or use fewer rows or less row spacing las filas son demasiado bajas: haz el área más alta o usa menos filas o menos espacio entre filas + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing una columna es demasiado estrecha: haz el área más ancha, dale más anchura a la columna o usa menos columnas o menos espacio entre columnas + field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} + two cells would both be named {name} dos celdas se llamarían {name} + a field named {name} already exists ya existe un campo llamado {name} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index aa83f81970..112359b607 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2481,3 +2481,23 @@ animation effects Off Désactivés Paragraph spacing Espacement des paragraphes Less Moins Other options Autres options + +# Prepare a form ▸ Text field table + Add text field table Ajouter un tableau de champs de texte + Text field table Tableau de champs de texte + Text Field Table Tableau de champs de texte + Columns Colonnes + Row spacing Espacement des lignes + Column spacing Espacement des colonnes + First row number Numéro de la première ligne + Column widths Largeurs des colonnes + Distribute evenly Répartir uniformément + Field names Noms des champs + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ou {row} est le numéro de ligne, {col} le numéro de colonne, {seq} le numéro de cellule. Sans aucun d'eux, le numéro de ligne est ajouté à la fin. + Column {n} Colonne {n} + Add fields Ajouter les champs + the rows are too short: make the area taller, or use fewer rows or less row spacing les lignes sont trop basses : agrandissez la zone en hauteur, ou utilisez moins de lignes ou un espacement des lignes plus petit + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing une colonne est trop étroite : élargissez la zone, donnez-lui plus de largeur, ou utilisez moins de colonnes ou un espacement des colonnes plus petit + field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} + two cells would both be named {name} deux cellules porteraient le nom {name} + a field named {name} already exists un champ nommé {name} existe déjà diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 123be343f3..72541431c8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2480,3 +2480,23 @@ animation effects Off Ki Paragraph spacing Bekezdéstávolság Less Kevesebb Other options Egyéb beállítások + +# Prepare a form ▸ Text field table + Add text field table Szövegmező-táblázat hozzáadása + Text field table Szövegmező-táblázat + Text Field Table Szövegmező-táblázat + Columns Oszlopok + Row spacing Sorköz + Column spacing Oszlopköz + First row number Első sor száma + Column widths Oszlopszélességek + Distribute evenly Egyenletes elosztás + Field names Mezőnevek + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. Az N vagy a {row} a sor száma, a {col} az oszlopé, a {seq} a cellaé. Ha egyik sincs, a sor száma a végére kerül. + Column {n} {n}. oszlop + Add fields Mezők hozzáadása + the rows are too short: make the area taller, or use fewer rows or less row spacing a sorok túl alacsonyak: növeld a terület magasságát, vagy használj kevesebb sort vagy kisebb sorközt + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing egy oszlop túl keskeny: szélesítsd a területet, adj több helyet az oszlopnak, vagy használj kevesebb oszlopot vagy kisebb oszlopközt + field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} + two cells would both be named {name} két cella neve is {name} lenne + a field named {name} already exists már van {name} nevű mező diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 71715dc5d7..53e16c5cba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2693,3 +2693,27 @@ certificate SHA-256 digest Impronta SHA-256 Paragraph spacing Spaziatura paragrafi Less Meno Other options Altre opzioni + +# Prepare a form ▸ Text field table + Add text field table Aggiungi tabella di campi di testo + Text field table Tabella di campi di testo + Text Field Table Tabella di campi di testo + Columns Colonne + Row spacing Spaziatura tra le righe + Column spacing Spaziatura tra le colonne + First row number Numero della prima riga + Column widths Larghezze delle colonne + Distribute evenly Distribuisci uniformemente + Field names Nomi dei campi + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N o {row} è il numero di riga, {col} quello di colonna, {seq} quello di cella. Senza nessuno di essi, il numero di riga va alla fine. + Column {n} Colonna {n} + Add fields Aggiungi campi + a table has 1 to {max} rows una tabella ha da 1 a {max} righe + a table has 1 to {max} columns una tabella ha da 1 a {max} colonne + the first row number can be at most {max} il numero della prima riga può essere al massimo {max} + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 l'area, la spaziatura e le larghezze delle colonne devono essere numeri, la spaziatura 0 o più e ogni larghezza maggiore di 0 + the rows are too short: make the area taller, or use fewer rows or less row spacing le righe sono troppo basse: rendi l'area più alta oppure usa meno righe o meno spaziatura tra le righe + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing una colonna è troppo stretta: rendi l'area più larga, dai più spazio alla colonna oppure usa meno colonne o meno spaziatura tra le colonne + field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} + two cells would both be named {name} due celle si chiamerebbero entrambe {name} + a field named {name} already exists esiste già un campo chiamato {name} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f73e753def..c34658a320 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2481,3 +2481,23 @@ animation effects Off オフ Paragraph spacing 段落間隔 Less 折りたたむ Other options その他のオプション + +# Prepare a form ▸ Text field table + Add text field table テキストフィールドの表を追加 + Text field table テキストフィールドの表 + Text Field Table テキストフィールドの表 + Columns 列 + Row spacing 行の間隔 + Column spacing 列の間隔 + First row number 最初の行番号 + Column widths 列の幅 + Distribute evenly 均等に配分 + Field names フィールド名 + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N または {row} は行番号、{col} は列番号、{seq} はセル番号です。どれもない場合は、行番号が末尾に付きます。 + Column {n} 列 {n} + Add fields フィールドを追加 + the rows are too short: make the area taller, or use fewer rows or less row spacing 行の高さが足りません: 範囲を高くするか、行数または行の間隔を減らしてください + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 列の幅が足りません: 範囲を広げるか、その列の幅を増やすか、列数または列の間隔を減らしてください + field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} + two cells would both be named {name} 2 つのセルが同じ名前 {name} になります + a field named {name} already exists {name} という名前のフィールドは既にあります diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 7f113d4bfc..60fad67160 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2479,3 +2479,23 @@ animation effects Off Өшірулі Paragraph spacing Абзац аралығы Less Азырақ Other options Басқа параметрлер + +# Prepare a form ▸ Text field table + Add text field table Мәтін өрістерінің кестесін қосу + Text field table Мәтін өрістерінің кестесі + Text Field Table Мәтін өрістерінің кестесі + Columns Бағандар + Row spacing Жолдар аралығы + Column spacing Бағандар аралығы + First row number Бірінші жол нөмірі + Column widths Баған ендері + Distribute evenly Біркелкі бөлу + Field names Өріс атаулары + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N немесе {row} — жол нөмірі, {col} — баған нөмірі, {seq} — ұяшық нөмірі. Олардың ешқайсысы болмаса, жол нөмірі соңына қосылады. + Column {n} {n}-баған + Add fields Өрістерді қосу + the rows are too short: make the area taller, or use fewer rows or less row spacing жолдар тым аласа: аймақты биіктетіңіз немесе жолдар санын не жолдар аралығын азайтыңыз + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing баған тым тар: аймақты кеңейтіңіз, бағанға көбірек ен беріңіз немесе бағандар санын не бағандар аралығын азайтыңыз + field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} + two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді + a field named {name} already exists {name} атты өріс бар diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 6448c6949e..ad2bf574fd 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2731,3 +2731,27 @@ theme Appearance Išvaizda Paragraph spacing Tarpas tarp pastraipų Less Mažiau Other options Kitos parinktys + +# Prepare a form ▸ Text field table + Add text field table Pridėti teksto laukų lentelę + Text field table Teksto laukų lentelė + Text Field Table Teksto laukų lentelė + Columns Stulpeliai + Row spacing Tarpas tarp eilučių + Column spacing Tarpas tarp stulpelių + First row number Pirmosios eilutės numeris + Column widths Stulpelių pločiai + Distribute evenly Paskirstyti tolygiai + Field names Laukų pavadinimai + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N arba {row} – eilutės numeris, {col} – stulpelio numeris, {seq} – langelio numeris. Jei nė vieno nėra, eilutės numeris pridedamas gale. + Column {n} Stulpelis {n} + Add fields Pridėti laukus + the rows are too short: make the area taller, or use fewer rows or less row spacing eilutės per žemos: padidinkite srities aukštį arba naudokite mažiau eilučių ar mažesnį tarpą tarp jų + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing stulpelis per siauras: praplatinkite sritį, skirkite stulpeliui daugiau pločio arba naudokite mažiau stulpelių ar mažesnį tarpą tarp jų + field names can't contain a period: {name} laukų pavadinimuose negali būti taško: {name} + two cells would both be named {name} du langeliai būtų pavadinti {name} + a field named {name} already exists laukas pavadinimu {name} jau yra + a table has 1 to {max} rows lentelėje gali būti nuo 1 iki {max} eilučių + a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių + the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 20c0fd0467..75171dfb29 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -1290,7 +1290,7 @@ mod tests { /// Spanish says everything the layers below word for themselves: what the accessibility /// checker finds and what its saved report calls things, what the PDF/A verifier reports, what /// a signature's details say and the changes they name, what can be wrong with the pages to - /// print, and what an edit is called in the history. + /// print or with a table of text fields, and what an edit is called in the history. /// /// These lists are the whole point of the engine handing the wording over apart from the /// document's own words: a catalog that covers them says those sentences in its own language. @@ -1298,7 +1298,7 @@ mod tests { #[test] fn spanish_covers_what_the_engine_says() { let es = Lang::from_code("es").expect("es registered"); - let lists: [(&str, &[&str]); 8] = [ + let lists: [(&str, &[&str]); 9] = [ ("accessibility finding", pdfcraft_engine::a11y::WORDINGS), ("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS), ("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS), @@ -1307,6 +1307,7 @@ mod tests { ("pages to print", pdfcraft_engine::print::WORDINGS), ("edit label", pdfcraft_engine::EDIT_LABELS), ("batch label", pdfcraft_engine::BATCH_LABELS), + ("text field table mistake", pdfcraft_engine::field_table::WORDINGS), ]; for (what, list) in lists { assert!(!list.is_empty(), "no {what} wordings to cover"); @@ -1322,11 +1323,12 @@ mod tests { #[test] fn spanish_keeps_the_holes_in_what_the_engine_says() { let es = Lang::from_code("es").expect("es registered"); - let lists: [&[&str]; 4] = [ + let lists: [&[&str]; 5] = [ pdfcraft_engine::a11y::WORDINGS, pdfcraft_engine::pdfa::WORDINGS, pdfcraft_engine::sign::pdf::WORDINGS, pdfcraft_engine::print::WORDINGS, + pdfcraft_engine::field_table::WORDINGS, ]; for wording in lists.into_iter().flatten() { let said = tr(es, wording); diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 578b512bb7..00d696417a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2457,3 +2457,23 @@ animation effects Off Wyłączone Paragraph spacing Odstęp między akapitami Less Mniej Other options Inne opcje + +# Prepare a form ▸ Text field table + Add text field table Dodaj tabelę pól tekstowych + Text field table Tabela pól tekstowych + Text Field Table Tabela pól tekstowych + Columns Kolumny + Row spacing Odstęp między wierszami + Column spacing Odstęp między kolumnami + First row number Numer pierwszego wiersza + Column widths Szerokości kolumn + Distribute evenly Rozłóż równomiernie + Field names Nazwy pól + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N lub {row} to numer wiersza, {col} numer kolumny, a {seq} numer komórki. Bez żadnego z nich numer wiersza trafia na koniec. + Column {n} Kolumna {n} + Add fields Dodaj pola + the rows are too short: make the area taller, or use fewer rows or less row spacing wiersze są za niskie: powiększ obszar w pionie albo użyj mniej wierszy lub mniejszego odstępu między wierszami + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing kolumna jest za wąska: poszerz obszar, daj kolumnie więcej miejsca albo użyj mniej kolumn lub mniejszego odstępu między kolumnami + field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} + two cells would both be named {name} dwie komórki miałyby nazwę {name} + a field named {name} already exists pole o nazwie {name} już istnieje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ee12fe7ab2..ef8704423f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2495,3 +2495,23 @@ animation effects Off Desativados Paragraph spacing Espaçamento entre parágrafos Less Menos Other options Outras opções + +# Prepare a form ▸ Text field table + Add text field table Adicionar tabela de campos de texto + Text field table Tabela de campos de texto + Text Field Table Tabela de campos de texto + Columns Colunas + Row spacing Espaçamento entre linhas + Column spacing Espaçamento entre colunas + First row number Número da primeira linha + Column widths Larguras das colunas + Distribute evenly Distribuir igualmente + Field names Nomes dos campos + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ou {row} é o número da linha, {col} o da coluna e {seq} o da célula. Sem nenhum deles, o número da linha vai no final. + Column {n} Coluna {n} + Add fields Adicionar campos + the rows are too short: make the area taller, or use fewer rows or less row spacing as linhas estão baixas demais: deixe a área mais alta ou use menos linhas ou menos espaçamento entre linhas + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing uma coluna está estreita demais: deixe a área mais larga, dê mais largura à coluna ou use menos colunas ou menos espaçamento entre colunas + field names can't contain a period: {name} nomes de campo não podem conter ponto: {name} + two cells would both be named {name} duas células teriam o nome {name} + a field named {name} already exists já existe um campo chamado {name} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3baec45600..fa2c6e6c33 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2394,3 +2394,23 @@ animation effects Off Выкл Paragraph spacing Интервал между абзацами Less Меньше Other options Другие параметры + +# Prepare a form ▸ Text field table + Add text field table Добавить таблицу текстовых полей + Text field table Таблица текстовых полей + Text Field Table Таблица текстовых полей + Columns Столбцы + Row spacing Интервал между строками + Column spacing Интервал между столбцами + First row number Номер первой строки + Column widths Ширина столбцов + Distribute evenly Распределить равномерно + Field names Имена полей + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N или {row} — номер строки, {col} — номер столбца, {seq} — номер ячейки. Если их нет, номер строки добавляется в конец. + Column {n} Столбец {n} + Add fields Добавить поля + the rows are too short: make the area taller, or use fewer rows or less row spacing строки слишком низкие: увеличьте высоту области, уменьшите число строк или интервал между ними + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing столбец слишком узкий: расширьте область, отдайте столбцу больше ширины, уменьшите число столбцов или интервал между ними + field names can't contain a period: {name} имена полей не могут содержать точку: {name} + two cells would both be named {name} две ячейки получили бы одно имя {name} + a field named {name} already exists поле с именем {name} уже существует diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 5ba9dc90c3..a829e8d00a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2486,3 +2486,23 @@ animation effects Off Izklopljeni Paragraph spacing Razmik med odstavki Less Manj Other options Druge možnosti + +# Prepare a form ▸ Text field table + Add text field table Dodaj tabelo besedilnih polj + Text field table Tabela besedilnih polj + Text Field Table Tabela besedilnih polj + Columns Stolpci + Row spacing Razmik med vrsticami + Column spacing Razmik med stolpci + First row number Številka prve vrstice + Column widths Širine stolpcev + Distribute evenly Enakomerno porazdeli + Field names Imena polj + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N ali {row} je številka vrstice, {col} številka stolpca in {seq} številka celice. Brez njih se številka vrstice doda na konec. + Column {n} Stolpec {n} + Add fields Dodaj polja + the rows are too short: make the area taller, or use fewer rows or less row spacing vrstice so prenizke: povečajte višino območja ali uporabite manj vrstic ali manjši razmik med vrsticami + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing stolpec je preozek: razširite območje, stolpcu dajte več širine ali uporabite manj stolpcev ali manjši razmik med stolpci + field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} + two cells would both be named {name} dve celici bi imeli enako ime {name} + a field named {name} already exists polje z imenom {name} že obstaja diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index e0d6a2b34c..ad682462e9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2390,3 +2390,23 @@ animation effects Off ఆఫ్ Paragraph spacing పేరా అంతరం Less తక్కువ Other options ఇతర ఎంపికలు + +# Prepare a form ▸ Text field table + Add text field table టెక్స్ట్ ఫీల్డ్ పట్టిక జోడించు + Text field table టెక్స్ట్ ఫీల్డ్ పట్టిక + Text Field Table టెక్స్ట్ ఫీల్డ్ పట్టిక + Columns నిలువు వరుసలు + Row spacing వరుసల మధ్య దూరం + Column spacing నిలువు వరుసల మధ్య దూరం + First row number మొదటి వరుస సంఖ్య + Column widths నిలువు వరుసల వెడల్పులు + Distribute evenly సమానంగా పంచు + Field names ఫీల్డ్ పేర్లు + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N లేదా {row} వరుస సంఖ్య, {col} నిలువు వరుస సంఖ్య, {seq} గడి సంఖ్య. వీటిలో ఏదీ లేకపోతే, వరుస సంఖ్య చివర చేరుతుంది. + Column {n} నిలువు వరుస {n} + Add fields ఫీల్డ్‌లు జోడించు + the rows are too short: make the area taller, or use fewer rows or less row spacing వరుసలు చాలా తక్కువ ఎత్తులో ఉన్నాయి: ప్రాంతాన్ని ఎత్తుగా చేయండి, లేదా తక్కువ వరుసలు లేదా తక్కువ వరుస దూరం వాడండి + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing ఒక నిలువు వరుస చాలా ఇరుకుగా ఉంది: ప్రాంతాన్ని వెడల్పు చేయండి, ఆ నిలువు వరుసకు ఎక్కువ వెడల్పు ఇవ్వండి, లేదా తక్కువ నిలువు వరుసలు లేదా తక్కువ దూరం వాడండి + field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} + two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది + a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 48d34a30da..cb95010d4b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2477,3 +2477,23 @@ animation effects Off Kapalı Paragraph spacing Paragraf aralığı Less Daha az Other options Diğer seçenekler + +# Prepare a form ▸ Text field table + Add text field table Metin alanı tablosu ekle + Text field table Metin alanı tablosu + Text Field Table Metin alanı tablosu + Columns Sütunlar + Row spacing Satır aralığı + Column spacing Sütun aralığı + First row number İlk satır numarası + Column widths Sütun genişlikleri + Distribute evenly Eşit dağıt + Field names Alan adları + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N veya {row} satır numarası, {col} sütun numarası, {seq} hücre numarasıdır. Hiçbiri yoksa satır numarası sona eklenir. + Column {n} Sütun {n} + Add fields Alanları ekle + the rows are too short: make the area taller, or use fewer rows or less row spacing satırlar çok kısa: alanı uzatın ya da daha az satır veya daha az satır aralığı kullanın + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing bir sütun çok dar: alanı genişletin, sütuna daha fazla yer verin ya da daha az sütun veya daha az sütun aralığı kullanın + field names can't contain a period: {name} alan adları nokta içeremez: {name} + two cells would both be named {name} iki hücrenin de adı {name} olurdu + a field named {name} already exists {name} adlı bir alan zaten var diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 246a4a2c56..a23f108b53 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2503,3 +2503,23 @@ animation effects Off Вимкнено Paragraph spacing Інтервал між абзацами Less Менше Other options Інші параметри + +# Prepare a form ▸ Text field table + Add text field table Додати таблицю текстових полів + Text field table Таблиця текстових полів + Text Field Table Таблиця текстових полів + Columns Стовпці + Row spacing Інтервал між рядками + Column spacing Інтервал між стовпцями + First row number Номер першого рядка + Column widths Ширина стовпців + Distribute evenly Розподілити рівномірно + Field names Імена полів + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N або {row} — номер рядка, {col} — номер стовпця, {seq} — номер клітинки. Якщо їх немає, номер рядка додається в кінці. + Column {n} Стовпець {n} + Add fields Додати поля + the rows are too short: make the area taller, or use fewer rows or less row spacing рядки занадто низькі: збільште висоту області, зменште кількість рядків або інтервал між ними + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing стовпець занадто вузький: розширте область, дайте стовпцю більше ширини, зменште кількість стовпців або інтервал між ними + field names can't contain a period: {name} імена полів не можуть містити крапку: {name} + two cells would both be named {name} дві клітинки мали б однакове ім'я {name} + a field named {name} already exists поле з іменем {name} уже існує diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 56caf38859..e25d358364 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2733,3 +2733,27 @@ key Space Space # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. + +# Prepare a form ▸ Text field table + Add text field table Thêm bảng trường văn bản + Text field table Bảng trường văn bản + Text Field Table Bảng trường văn bản + Columns Cột + Row spacing Khoảng cách hàng + Column spacing Khoảng cách cột + First row number Số hàng đầu tiên + Column widths Độ rộng cột + Distribute evenly Phân bố đều + Field names Tên trường + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N hoặc {row} là số hàng, {col} là số cột, {seq} là số ô. Nếu không có cái nào, số hàng được thêm vào cuối. + Column {n} Cột {n} + Add fields Thêm các trường + the rows are too short: make the area taller, or use fewer rows or less row spacing các hàng quá thấp: hãy làm vùng cao hơn, hoặc dùng ít hàng hơn hay khoảng cách hàng nhỏ hơn + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing một cột quá hẹp: hãy làm vùng rộng hơn, cho cột thêm độ rộng, hoặc dùng ít cột hơn hay khoảng cách cột nhỏ hơn + field names can't contain a period: {name} tên trường không được chứa dấu chấm: {name} + two cells would both be named {name} hai ô sẽ cùng có tên {name} + a field named {name} already exists đã có trường tên {name} + a table has 1 to {max} rows một bảng có từ 1 đến {max} hàng + a table has 1 to {max} columns một bảng có từ 1 đến {max} cột + the first row number can be at most {max} số hàng đầu tiên tối đa là {max} + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 645c0ab301..f62891fb0e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2445,3 +2445,23 @@ animation effects Off 关 Paragraph spacing 段落间距 Less 收起 Other options 其他选项 + +# Prepare a form ▸ Text field table + Add text field table 添加文本字段表格 + Text field table 文本字段表格 + Text Field Table 文本字段表格 + Columns 列 + Row spacing 行间距 + Column spacing 列间距 + First row number 起始行号 + Column widths 列宽 + Distribute evenly 平均分布 + Field names 字段名称 + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N 或 {row} 表示行号,{col} 表示列号,{seq} 表示单元格序号。如果都没有,行号加在末尾。 + Column {n} 第 {n} 列 + Add fields 添加字段 + the rows are too short: make the area taller, or use fewer rows or less row spacing 行太矮:请增加区域高度,或减少行数或行间距 + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 某列太窄:请加宽区域、给该列更多宽度,或减少列数或列间距 + field names can't contain a period: {name} 字段名称不能包含句点:{name} + two cells would both be named {name} 两个单元格的名称都会是 {name} + a field named {name} already exists 已存在名为 {name} 的字段 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 712f0666a5..0b5d8d6462 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2431,3 +2431,23 @@ animation effects Off 關 Paragraph spacing 段落間距 Less 收起 Other options 其他選項 + +# Prepare a form ▸ Text field table + Add text field table 新增文字欄位表格 + Text field table 文字欄位表格 + Text Field Table 文字欄位表格 + Columns 欄 + Row spacing 列間距 + Column spacing 欄間距 + First row number 起始列號 + Column widths 欄寬 + Distribute evenly 平均分配 + Field names 欄位名稱 + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N 或 {row} 代表列號,{col} 代表欄號,{seq} 代表儲存格序號。若都沒有,列號會加在結尾。 + Column {n} 第 {n} 欄 + Add fields 新增欄位 + the rows are too short: make the area taller, or use fewer rows or less row spacing 列太矮:請加高區域,或減少列數或列間距 + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing 某欄太窄:請加寬區域、給該欄更多寬度,或減少欄數或欄間距 + field names can't contain a period: {name} 欄位名稱不能包含句點:{name} + two cells would both be named {name} 兩個儲存格的名稱都會是 {name} + a field named {name} already exists 已有名為 {name} 的欄位 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c3b882779c..f404f564d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,7 @@ mod bidi; pub mod bulk_fields; mod dialogs; mod edit_text_ui; +pub mod field_table_ui; pub use edit_text_ui::CropMode; mod editing; #[cfg(not(target_arch = "wasm32"))] @@ -334,6 +335,8 @@ pub enum Dialog { Optimize, /// Prepare a form ▸ right-click a field ▸ Duplicate. DuplicateField, + /// Prepare a form ▸ Text field table: a movable window rather than a modal (`field_table_ui`). + FieldTable, /// Check for accessibility ▸ Accessibility Checker Options. AccessibilityOptions, /// Scan & OCR ▸ Recognize text. @@ -736,6 +739,8 @@ pub struct PdfCraftApp { pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. pub duplicate_draft: Option, + /// Text field table: the table being set up, and the page it goes on. + pub field_table_draft: Option, /// Prepare a form ▸ Preview: fill the form instead of editing its fields. pub form_preview: bool, /// Where autosaves go (`None`: autosave off, e.g. on the web and in tests). @@ -1036,6 +1041,7 @@ impl PdfCraftApp { system_clipboard: true, attach_override: None, duplicate_draft: None, + field_table_draft: None, form_preview: false, window_title: String::new(), recovery: None, diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index a46611274d..fc01684bd2 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -17,12 +17,14 @@ use crate::widgets; const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Acrobat's field fill in Prepare Form (light blue). -const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59); +pub(crate) const FIELD_FILL: Color32 = Color32::from_rgba_premultiplied(0x1B, 0x34, 0x54, 0x59); /// The Add form components tools. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FieldTool { Text, + /// Rows and columns of text fields in the area drawn, set up in a window first. + TextTable, CheckBox, Radio, Combo, @@ -33,8 +35,9 @@ pub enum FieldTool { Signature, } -pub const FIELD_TOOLS: [FieldTool; 9] = [ +pub const FIELD_TOOLS: [FieldTool; 10] = [ FieldTool::Text, + FieldTool::TextTable, FieldTool::CheckBox, FieldTool::Radio, FieldTool::Combo, @@ -49,6 +52,7 @@ impl FieldTool { pub fn command(self) -> &'static str { match self { FieldTool::Text => "form.add.text", + FieldTool::TextTable => "form.add.text_table", FieldTool::CheckBox => "form.add.checkbox", FieldTool::Radio => "form.add.radio", FieldTool::Combo => "form.add.combo", @@ -67,6 +71,7 @@ impl FieldTool { pub fn label(self) -> &'static str { match self { FieldTool::Text => "Text field", + FieldTool::TextTable => "Text field table", FieldTool::CheckBox => "Checkbox", FieldTool::Radio => "Radio button", FieldTool::Combo => "Drop-down list", @@ -82,7 +87,8 @@ impl FieldTool { /// join it with the next free choice. pub fn new_field(self, join: Option<&FormField>) -> NewField { match self { - FieldTool::Text => NewField::Text { multiline: false }, + // A table's cells are text fields. + FieldTool::Text | FieldTool::TextTable => NewField::Text { multiline: false }, FieldTool::CheckBox => NewField::CheckBox, FieldTool::Radio => match join.filter(|f| f.kind == FormFieldKind::Radio) { Some(g) => { @@ -105,6 +111,7 @@ impl FieldTool { /// Width and height in points of a field placed with a single click. pub fn default_size(self) -> (f64, f64) { match self { + FieldTool::TextTable => (180.0, 110.0), FieldTool::CheckBox | FieldTool::Radio => (14.0, 14.0), FieldTool::List => (144.0, 54.0), FieldTool::Button => (72.0, 22.0), @@ -178,6 +185,23 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { [u[0] as f64, u[1] as f64] } +/// Where a table goes, in view space, for a screen rect drawn with the table tool: a click (a rect +/// under 4 pixels either way) puts the default size's top-left corner at the pointer, moved in from +/// the page's edges as far as it takes to stay on the page. +fn table_rect(xf: &PageXform, r: Rect) -> [f64; 4] { + let (x0, y0) = xf.screen_to_view(r.min); + if r.width() < 4.0 || r.height() < 4.0 { + let (pw, ph) = (f64::from(xf.pw), f64::from(xf.ph)); + let (dw, dh) = FieldTool::TextTable.default_size(); + // Smaller than the default only on a page smaller than it. + let (w, h) = (dw.min(pw), dh.min(ph)); + let (x, y) = (f64::from(x0).min(pw - w).max(0.0), f64::from(y0).min(ph - h).max(0.0)); + return [x, y, x + w, y + h]; + } + let (x1, y1) = xf.screen_to_view(r.max); + [x0, y0, x1, y1].map(f64::from) +} + /// A screen rect → a normalised user-space rect. fn user_rect(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max)); @@ -208,6 +232,8 @@ pub(crate) struct Outcome { pub properties: bool, /// A field was placed: go back to the Select tool. pub placed: bool, + /// A table of text fields was drawn: open its window for this page and area (view space). + pub table: Option<(usize, [f64; 4])>, } /// Pointer input on one page while preparing a form. @@ -223,7 +249,7 @@ pub(crate) fn page_input( allowed: bool, view: &mut DocView, ) -> Outcome { - let mut out = Outcome { consumed: false, properties: false, placed: false }; + let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); let prep = &mut view.prepare; @@ -267,6 +293,11 @@ pub(crate) fn page_input( } Grab::Draw(from) => { let Some(tool) = tool else { return out }; + if tool == FieldTool::TextTable { + out.table = Some((page, table_rect(xf, Rect::from_two_pos(from, end).intersect(xf.rect)))); + out.placed = true; + return out; + } let r = Rect::from_two_pos(from, end).intersect(xf.rect); let rect = user_rect(xf, info, page, r); let rect = if r.width() < 4.0 || r.height() < 4.0 { @@ -312,6 +343,11 @@ pub(crate) fn page_input( if resp.clicked() { // A click without a drag: finish at once. prep.grab = None; + if tool == FieldTool::TextTable { + out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO)))); + out.placed = true; + return out; + } let at = to_user(xf, info, page, p); let (w, h) = tool.default_size(); let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index 25e70fb810..61bfe472a6 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -2,9 +2,11 @@ //! Field Properties and deleting. use egui::Pos2; +use egui::accesskit::Role; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; +use egui_kittest::kittest::{Queryable, by}; +use pdfcraft_engine::field_table::TableColumn; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| { @@ -656,6 +658,233 @@ fn actions_tab_adds_and_removes_actions() { ); } +/// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by +/// 120 points from (40, 240) as displayed. The table's window opens for it. +fn draw_table(h: &mut Harness<'static, PdfCraftApp>) { + assert!(h.state_mut().execute("form.add.text_table")); + h.run_steps(2); + let (a, b) = (at(h, 40.0, 240.0), at(h, 240.0, 360.0)); + drag(h, a, b); + assert_eq!(h.state().dialog, Some(Dialog::FieldTable)); +} + +fn columns(patterns: &[(&str, f64)]) -> Vec { + patterns.iter().map(|(p, w)| TableColumn { pattern: (*p).to_owned(), width: *w }).collect() +} + +#[test] +fn a_text_field_table_is_drawn_set_up_and_added_in_one_step() { + let mut h = harness(); + let before = names(&h); + draw_table(&mut h); + assert_eq!(h.state().quick_tool, QuickTool::Select, "back to Select once the area is drawn"); + { + let s = h.state_mut(); + let id = s.views[0].id; + let d = s.field_table_draft.as_mut().expect("the table's window is open"); + assert_eq!((d.doc, d.page), (id, 0)); + let r = d.table.rect; + assert!([40.0, 240.0, 240.0, 360.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "drawn as displayed: {r:?}"); + d.table.rows = 2; + d.table.columns = columns(&[("desc_N", 3.0), ("price_N", 1.0)]); + } + h.run_steps(2); + h.get_by_label("Add fields").click(); + h.run_steps(3); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "added, the window closes"); + let added: Vec<_> = names(&h).into_iter().filter(|n| !before.contains(n)).collect(); + assert_eq!(added, ["desc_1", "price_1", "desc_2", "price_2"]); + // 150 and 50 points wide, 60 high, the first row at the top: user space has y up from 400. + let r = rect_of(&h, "desc_1"); + assert!([40.0, 100.0, 190.0, 160.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}"); + let r = rect_of(&h, "price_2"); + assert!([190.0, 40.0, 240.0, 100.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "{r:?}"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Add text field table")); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(names(&h), before, "one undo takes the whole table back"); +} + +#[test] +fn a_table_whose_names_are_taken_stays_open_saying_so() { + let mut h = harness(); + draw_table(&mut h); + h.get_by_label("Add fields").click(); + h.run_steps(3); + assert!(names(&h).contains(&"Table1_4".to_owned()), "{:?}", names(&h)); + // The next table takes the next free names on its own. + draw_table(&mut h); + assert_eq!(h.state().field_table_draft.as_ref().unwrap().table.columns[0].pattern, "Table2_N"); + // Named like the first, it can't be added: the window says why and keeps what was set up. + h.state_mut().field_table_draft.as_mut().unwrap().table.columns[0].pattern = "Table1_N".into(); + h.run_steps(2); + let count = names(&h).len(); + h.get_by_label("a field named \"Table1_1\" already exists"); + h.get_by_label("Add fields").click(); + h.run_steps(3); + assert_eq!(names(&h).len(), count, "nothing added"); + assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open"); + // Escape closes it, adding nothing. + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some(), names(&h).len()), (None, false, count)); +} + +/// Enter commits a number being typed. It used to add the table there and then, before the rest +/// of it was set up. +#[test] +fn enter_in_a_number_field_does_not_add_the_table() { + let mut h = harness(); + let before = names(&h).len(); + draw_table(&mut h); + let rows = h.query_all(by().role(Role::SpinButton)).next().map(|n| n.rect()).expect("the Rows field"); + h.hover_at(rows.center()); + h.drag_at(rows.center()); + h.run_steps(1); + h.drop_at(rows.center()); + h.run_steps(2); + h.key_press(egui::Key::Backspace); + h.event(egui::Event::Text("6".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(names(&h).len(), before, "nothing added"); + assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.rows), Some(6), "the number is in, the window open"); +} + +/// With 26 or more columns each pair of columns has less than twice the least share a divider +/// keeps, and dragging one panicked in `f64::clamp`. +#[test] +fn dragging_a_column_divider_between_many_columns() { + let mut h = harness(); + draw_table(&mut h); + h.state_mut().field_table_draft.as_mut().unwrap().table.columns = columns(&[("c{c}_N", 1.0); 30]); + h.run_steps(2); + let (above, below) = (h.get_by_label("Distribute evenly").rect(), h.get_by_label("Field names").rect()); + let p = egui::pos2(above.center().x - 120.0, (above.bottom() + below.top()) / 2.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + for dx in [-10.0, -20.0] { + h.hover_at(p + egui::vec2(dx, 0.0)); + h.run_steps(1); + } + h.drop_at(p + egui::vec2(-20.0, 0.0)); + h.run_steps(2); + let widths: Vec = h.state().field_table_draft.as_ref().unwrap().table.columns.iter().map(|c| c.width).collect(); + assert!(widths.iter().any(|w| (w - 1.0).abs() > 1e-9), "a divider moved: {widths:?}"); + assert!((widths.iter().sum::() - 30.0).abs() < 1e-9 && widths.iter().all(|w| *w > 0.0), "{widths:?}"); +} + +#[test] +fn the_table_window_closes_when_its_document_is_left() { + let mut h = harness(); + let blank = pdfcraft_engine::Session::new().create_blank(300.0, 400.0, 1).unwrap().to_vec(); + h.state_mut().open_bytes("other.pdf", None, blank).unwrap(); + h.state_mut().active = Some(0); + h.run_steps(4); + let before = names(&h).len(); + draw_table(&mut h); + // The other document's tab is still in reach of the pointer: choosing it closes the window. + let tab = h.get_all_by_label("other.pdf").next().map(|n| n.rect()).expect("its tab"); + h.hover_at(tab.center()); + h.run_steps(1); + h.drag_at(tab.center()); + h.run_steps(1); + h.drop_at(tab.center()); + h.run_steps(3); + assert_eq!(h.state().active, Some(1)); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false)); + h.state_mut().active = Some(0); + h.run_steps(2); + assert_eq!(names(&h).len(), before, "nothing added to either document"); + // So does leaving Prepare a form. + draw_table(&mut h); + h.state_mut().left_open = false; + h.run_steps(2); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false)); +} + +/// With its panel closed after the tool was picked, the form is still prepared and a table can be +/// drawn: its window opens Prepare a form again, which it belongs to. Drawing sends the tool back +/// to Select, so the window used to close itself at once, nothing being prepared any more. +#[test] +fn a_table_drawn_with_the_panel_closed_opens_it_again() { + let mut h = harness(); + let before = names(&h).len(); + assert!(h.state_mut().execute("form.add.text_table")); + h.run_steps(2); + h.state_mut().left_open = false; + h.run_steps(2); + assert!(h.state().is_preparing(), "the picked tool keeps the form prepared"); + let (a, b) = (at(&h, 40.0, 240.0), at(&h, 240.0, 360.0)); + drag(&mut h, a, b); + h.run_steps(3); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (Some(Dialog::FieldTable), true), "the window stays open"); + assert!(h.state().left_open && h.state().is_preparing(), "Prepare a form is open again"); + h.get_by_label("Add fields").click(); + h.run_steps(3); + assert!(names(&h).len() > before, "the table is added"); +} + +/// The window belongs to the page as it was when it opened: rotated (or moved, deleted, undone) +/// under it, the page would put the area the table was set up for somewhere else. +#[test] +fn the_table_window_closes_when_the_document_changes_under_it() { + let mut h = harness(); + let before = names(&h); + draw_table(&mut h); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + h.run_steps(2); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "closed by the rotation"); + assert_eq!(names(&h), before, "nothing added"); +} + +/// Escape in a name field leaves the field, as it does in any field; it closed the window too, +/// and the column setup with it. +#[test] +fn escape_in_a_name_field_leaves_the_field_not_the_table() { + let mut h = harness(); + draw_table(&mut h); + // The app's own text boxes (zoom, page number) are inputs too: take the one in the window. + let window = h.get_by_label("Text Field Table").rect(); + let field = + h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field"); + h.hover_at(field.center()); + h.drag_at(field.center()); + h.run_steps(1); + h.drop_at(field.center()); + h.run_steps(2); + h.event(egui::Event::Text("x".into())); + h.run_steps(1); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::FieldTable), "still open"); + assert_eq!(h.state().field_table_draft.as_ref().map(|d| d.table.columns[0].pattern.as_str()), Some("Table1_Nx"), "what was typed stays"); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert_eq!((h.state().dialog, h.state().field_table_draft.is_some()), (None, false), "a second Escape closes it"); +} + +/// A click puts the default 180 × 110 table's corner at the pointer; near the page's edges it is +/// moved in so that all of it is on the page (here 5 pt from the right, 100 pt from the bottom). +#[test] +fn a_table_clicked_near_the_page_edge_stays_on_the_page() { + let mut h = harness(); + assert!(h.state_mut().execute("form.add.text_table")); + h.run_steps(2); + let p = at(&h, 295.0, 300.0); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + let r = h.state().field_table_draft.as_ref().expect("the table's window").table.rect; + assert!([120.0, 290.0, 300.0, 400.0].iter().zip(r).all(|(w, g)| (w - g).abs() < 0.5), "on the 300 × 400 page: {r:?}"); +} + /// Prepare a form ▸ Calculation order opens as a page of its own, with the form still being /// prepared, says when nothing calculates, and Back returns to Prepare a form. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index 201cc1d6c3..c7c2f44797 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -135,7 +135,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Done since: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. (#2892). Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values.
Done since: Text field table: an area divided into rows and columns of named text fields, added as one undoable edit, with `form_add_field_table`; copying an existing field into a grid remains. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | @@ -205,6 +205,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-20 | minor | M6: Prepare a form ▸ Calculation order: formulas and values, what is wrong and Fix order, Up/Down, Alt+↑/↓ and drag, links on the page. Estimates unchanged. | | 2026-10-20 | minor | M6: `form_calculation_order` reads, checks, fixes and sets the calculation order; an edited formula keeps its place in `/CO`. Estimates unchanged. | | 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. | +| 2026-10-19 | minor | M6: Text field table (rows and columns of text fields as one edit, `form_add_field_table`); estimates unchanged | | 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. | | 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. | | 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad00b0115d..724cbb5604 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5025,11 +5025,16 @@ title = "Create multiple copies of a field in a grid" area = "F" tier = "P2" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["form.add.text_table"] +tools = ["form_add_field_table"] +evidence = [ + "crates/forms/src/table.rs::a_grid_fills_its_area_with_gaps_between_the_cells", + "crates/engine/src/field_table.rs::a_table_is_added_and_undone_as_one_step", + "crates/automation/tests/automation.rs::adding_a_field_table_through_tools", + "crates/ui-egui/tests/prepare.rs::a_text_field_table_is_drawn_set_up_and_added_in_one_step", +] +notes = "Prepare a form ▸ Text field table: drag an area, then set rows, columns, spacing, column widths (draggable dividers) and a name pattern per column in a movable window, with the table previewed on the page. Added as one undoable edit, or refused whole with the reason (a cell under 4 pt, a name with a period, a repeated or existing name); form_add_field_table does the same headlessly. Missing: copying an existing field of any type, with its properties, into a grid from its context menu, which is what this entry describes; the table only creates new text fields." [[feature]] id = "form.rename-lock-fields" From 88dad98dd478d255399b1405b33765f4da542704 Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Thu, 22 Oct 2026 04:05:23 +0200 Subject: [PATCH 20/20] =?UTF-8?q?M14:=20add=20Norwegian=20Bokm=C3=A5l=20in?= =?UTF-8?q?terface=20and=20date=20language=20(#1836)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * M14: add Norwegian Bokmål interface and date language A 2,592-entry `nb.tsv` covering every key the other bundled catalogs have, written from the meaning of the English labels (clean-room); terminology reviewed by a native-speaking contributor. Registers `nb` with the English one/other plural rule, adds Norwegian month and weekday names for Fill & Sign dates, resolves `no` and `nn` locales to Bokmål until a Nynorsk catalog exists, attributes the catalog and records it in parity. Co-Authored-By: Claude Fable 5.1 * M14: translate the strings main added since the Norwegian catalog was written 69 keys (signature appearance labels, word count, full-screen prompt, print as image, image crop, redaction mark appearance, …) and the attribution hash. Co-Authored-By: Claude Fable 5.1 * M14: translate the picture-menu and label-paper strings in the Norwegian catalog Seven keys main added (Cut/Redact Image, Create Link…, Recognize Text…, Turn upside down, the 4 x 6 in label paper) and the attribution hash. Co-Authored-By: Claude Fable 5.1 * M14: translate the page-group folding commands in the Norwegian catalog Co-Authored-By: Claude Fable 5.1 * M14: drop the superseded localization summaries left beside the current ones Concurrent merges on main left older copies of several localization summaries next to their replacements. Review of #1836 flagged them: - localization-parity.md: remove the 2026-10-13 status line and the 14-language summary that still listed Norwegian as missing; the current summary keeps the Italian (and now Norwegian Bokmål) measurement dates. - localization.md: keep one catalog-coverage paragraph (now naming Norwegian Bokmål, up to about 2,700 entries) and one font paragraph. The font paragraph folds in what only the older copies said: the installed Chinese face fallback (#826) and Slovenian's Latin faces and four plural forms. - roadmap.md: end the M14 Missing list before the Fontconfig note. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com> Co-authored-by: aiongg --- ATTRIBUTION.md | 3 +- ATTRIBUTION.toml | 11 + ROADMAP.md | 1 + crates/engine/src/dates.rs | 14 +- crates/ui-egui/src/i18n/mod.rs | 36 +- crates/ui-egui/src/i18n/nb.tsv | 2692 ++++++++++++++++++++++++++++++++ crates/ui-egui/tests/fonts.rs | 3 +- docs/localization-parity.md | 15 +- docs/localization.md | 16 +- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 4 +- 11 files changed, 2773 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/i18n/nb.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 75ebd1d7ca..fe90f4b1e3 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (276) +## In this repository (277) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -280,6 +280,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 23ecc1903b..93a5644c2c 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3377,6 +3377,17 @@ kind = "image" usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" +[[asset]] +path = "crates/ui-egui/src/i18n/nb.tsv" +title = "Norwegian Bokmål interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "ded29f83905d714188a0c1208a478f0da928556a309743824113d749b78ff0fa" + [[asset]] path = "assets/icons/superscript.svg" title = "Lucide icon \"superscript\"" diff --git a/ROADMAP.md b/ROADMAP.md index cb2b36028b..fd2cd7479f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -219,6 +219,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. - **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor). +- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged. - **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged. - **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test. - **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged. diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 8c14a5b1b3..7563247a84 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", /// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// the interface languages, in the same order and with the same names. -pub static DATE_LANGUAGES: [DateLanguage; 23] = [ +pub static DATE_LANGUAGES: [DateLanguage; 24] = [ DateLanguage { code: "en", name: "English", @@ -398,6 +398,15 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [ days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"], days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"], }, + DateLanguage { + code: "nb", + name: "Norsk bokmål", + months: ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"], + months_with_day: None, + months_short: ["jan.", "feb.", "mar.", "apr.", "mai", "jun.", "jul.", "aug.", "sep.", "okt.", "nov.", "des."], + days: ["søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag"], + days_short: ["søn.", "man.", "tir.", "ons.", "tor.", "fre.", "lør."], + }, ]; /// The date language for `code` (any case), or `None` when there's none. @@ -701,6 +710,8 @@ mod tests { assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日"); assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六"); assert_eq!(in_lang("ddd", "zh-hant"), "週六"); + assert_eq!(in_lang("dddd d. mmmm yyyy", "nb"), "lørdag 7. mars 2026"); + assert_eq!(in_lang("ddd d. mmm", "nb"), "lør. 7. mar."); assert_eq!(date_language("PT-BR").map(|l| l.code), Some("pt-br")); assert!(date_language("xx").is_none()); for l in &DATE_LANGUAGES { @@ -765,6 +776,7 @@ mod tests { assert_eq!(unwritable("8. října 2026"), "ř"); assert_eq!(unwritable("2026年10月8日"), "年月日"); assert_eq!(unwritable("sábado, 7 de março – 2026?"), "", "Western European, dashes and ? are fine"); + assert_eq!(unwritable("lørdag 7. mars, søn. 8. mai (æøå ÆØÅ)"), "", "Norwegian letters are Western European"); let mut s = Session::new().with_clock(|| 1_700_000_000); s.set_date_format("d. mmmm yyyy").unwrap(); assert_eq!(s.today_text_for_pdf(None, Some("es")).as_deref(), Ok("14. noviembre 2023")); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 75171dfb29..ac63bfaec2 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -147,7 +147,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 23] = [ +pub static LANGUAGES: [LangInfo; 24] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -210,6 +210,9 @@ pub static LANGUAGES: [LangInfo; 23] = [ // Vietnamese; every `vi-*` locale (`vi-VN`, `vi_VN.UTF-8`) resolves here. CLDR `vi` counts have // one form ("other"), so `@plural` entries list a single form. LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, + // Norwegian Bokmål; every `nb-*` locale resolves here, and so do general Norwegian (`no-*`) and + // Nynorsk (`nn-*`), which have no catalog of their own (see `candidates`). + LangInfo { code: "nb", name: "Norsk bokmål", source: include_str!("nb.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -316,6 +319,11 @@ fn candidates(tag: &str) -> Vec { // The only Portuguese catalog is Brazilian; other regions use it rather than English. out.insert(out.len().saturating_sub(1), "pt-br".to_string()); } + if matches!(primary, "no" | "nn") { + // The only Norwegian catalog is Bokmål. `no` (Norwegian in general) and Nynorsk readers use + // it rather than English; last, so a catalog of their own would win. + out.push("nb".to_string()); + } out } @@ -655,6 +663,9 @@ mod tests { assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]); assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]); assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]); + assert_eq!(candidates("nb_NO.UTF-8"), ["nb-no", "nb"]); + assert_eq!(candidates("no_NO.UTF-8"), ["no-no", "no", "nb"]); + assert_eq!(candidates("nn-NO"), ["nn-no", "nn", "nb"]); } #[test] @@ -2203,6 +2214,29 @@ mod tests { assert!(restored.set_option("language", "xx").is_err()); } + #[test] + fn norwegian_bokmal_is_registered_and_persists() { + let nb = Lang::from_code("nb").expect("nb registered"); + assert_eq!(nb.name(), "Norsk bokmål"); + assert_eq!(normalize_pref("NB"), Some("nb")); + assert_eq!(normalize_pref("no"), None, "only exact codes are preferences"); + // Current systems say `nb`; older ones and some Linux setups say `no`. Nynorsk is `nn`. + for tag in ["nb", "nb-NO", "nb_NO.UTF-8", "no", "no_NO.UTF-8", "NO-no", "nn", "nn_NO.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(nb), "{tag}"); + } + assert_eq!(first_supported("(\n \"nb-NO\",\n \"en-US\"\n)\n"), Some(nb)); + assert_eq!(first_supported("nn-NO\r\nen-US\r\n"), Some(nb)); + assert_eq!((0..=3).map(|n| (nb.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + assert_eq!(tr(nb, "Allow"), "Tillat"); + assert_eq!(tr_ctx(nb, "key", "Space"), "Mellomrom"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "NB").unwrap(); + assert_eq!(app.language, "nb"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "nb"); + } + #[test] fn lithuanian_covers_commands_and_catalogue() { let lt = Lang::from_code("lt").expect("lt registered"); diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv new file mode 100644 index 0000000000..d0b542f7b0 --- /dev/null +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -0,0 +1,2692 @@ +# Norwegian Bokmål (nb) catalog. Format: see the header of ja.tsv +# (context source translation; @id, @plural and other contexts; escapes \n \t \\). +# Plural forms (see `plural_one_other` in i18n/mod.rs): one (1) | other (0, 2, 3 ...). +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Norwegian computing vocabulary. No proprietary localisation resource, string table or product UI +# (Acrobat's included) was consulted or copied; add entries the same way. +# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated; +# the trailing "…" of dialog commands is kept. + + Allow Tillat + Cancel Avbryt + Only allow this if you trust the source document. Tillat dette bare hvis du stoler på kildedokumentet. + Open this local PDF? Åpne denne lokale PDF-filen? + This document is trying to open a local file: Dette dokumentet prøver å åpne en lokal fil: + + Menu Meny + Edit Rediger + Pages Sider + View Vis + Help Hjelp + Zoom Zoom + Actual size Faktisk størrelse + Zoom to page level Zoom til sidenivå + Fit to width Tilpass til bredde + Display theme Visningstema + Side panels Sidepaneler + Preferences Innstillinger + Preferences… Innstillinger… + Interface language Grensesnittspråk + Interface size Grensesnittstørrelse + Auto ({percent}%) Auto ({percent}%) + Identity Identitet + Name on new comments Navn på nye kommentarer + Open… Åpne… + Open Recent Åpne nylige + Clear Recent Files Tøm nylige filer + Remove from Recent Fjern fra nylige + No recent files Ingen nylige filer + New blank PDF Ny tom PDF + Create PDF from file… Opprett PDF fra fil… + Create PDF from images… Opprett PDF fra bilder… + Create PDF from multiple files… Opprett PDF fra flere filer… + Create separate PDFs… Opprett separate PDF-er… + Create PDF from clipboard Opprett PDF fra utklippstavlen + Combine files… Slå sammen filer… + Save Lagre + Save as… Lagre som… + Close file Lukk fil + Close all Lukk alle + Revert Tilbakestill til lagret + Print… Skriv ut… + Document properties… Dokumentegenskaper… + Find… Finn… + Advanced search… Avansert søk… + Copy Kopier + Cut Klipp ut + Paste Lim inn + Delete Slett + Select all Merk alt + OK OK + Apply Bruk + Close Lukk + Back Tilbake + Next Neste + Previous Forrige + Name Navn + File name Filnavn + Location Plassering + Browse… Bla gjennom… + Choose… Velg… + Search Søk + Clear Tøm + Refresh Oppdater + Loading… Laster… + Save changes? Lagre endringer? + Don't save Ikke lagre + Page Side + Page {n} Side {n} + of av + Fit one full page Tilpass én hel side + Single page Enkeltside + Two-page view Tosidevisning + Continuous scrolling Kontinuerlig rulling + Page display Sidevisning + Default page display Standard sidevisning + Default zoom Standard zoom + Full screen mode Fullskjermmodus + Read mode Lesemodus + Comments Kommentarer + Comment Kommentar + Add comment Legg til kommentar + Delete comment Slett kommentar + Reply Svar + Highlight Uthev + Underline Understrek + Strikethrough Gjennomstreking + Text Tekst + Font Skrift + Size Størrelse + Color Farge + Opacity Gjennomsiktighet + Width Bredde + Height Høyde + Rotation Rotasjon + More Mer + Settings Innstillinger + General Generelt + Language Språk + Appearance Utseende + Theme Tema + Light Lyst + Dark Mørkt + System System + Open file Åpne fil + Save file Lagre fil + Export Eksporter + Import Importer + Export to image… Eksporter til bilde… + Export to text… Eksporter til tekst… + Export to Word… Eksporter til Word… + Export to HTML… Eksporter til HTML… + Export to RTF… Eksporter til RTF… + Reduce file size… Reduser filstørrelse… + Optimize PDF… Optimaliser PDF… + Protect using password… Beskytt med passord… + Remove security Fjern sikkerhet + Security properties… Sikkerhetsegenskaper… + Organize pages Organiser sider + Rotate pages… Roter sider… + Delete pages Slett sider + Insert blank page Sett inn tom side + Insert pages from file… Sett inn sider fra fil… + Replace pages… Erstatt sider… + Extract pages… Trekk ut sider… + Split document… Del opp dokument… + Number pages… Nummerer sider… + Bookmarks Bokmerker + New bookmark Nytt bokmerke + Forms Skjemaer + Form fields Skjemafelt + Signatures Signaturer + Sign Signer + Fill & Sign Fyll ut og signer + Accessibility Tilgjengelighet + Help… Hjelp… + Check for updates… Se etter oppdateringer… + About PdfCraft Om PdfCraft + Automatically Automatisk + Never Aldri + Always Alltid + Yes Ja + No Nei + On På + Off Av + Enabled Slått på + Disabled Slått av + Required Påkrevd + Optional Valgfritt + None Ingen + Unknown Ukjent + Error Feil + Warning Advarsel + Information Informasjon + Success Vellykket + Failed Mislyktes + File Fil + Folder Mappe + Choose a file… Velg en fil… + Choose a folder… Velg en mappe… + Open folder Åpne mappe + Create folder Opprett mappe + Remove Fjern + Rename Gi nytt navn + Duplicate Dupliser + Move Flytt + Copy to clipboard Kopier til utklippstavlen + Copied to clipboard Kopiert til utklippstavlen + Undo Angre + Redo Gjør om + Keyboard shortcuts Hurtigtaster + Find tools and commands… Finn verktøy og kommandoer… + Take a snapshot Ta et øyeblikksbilde + Organize Organiser + Comment and review Kommenter og gjennomgå + Prepare a form Lag et skjema + Protect Beskytt + Print Skriv ut + Create Opprett + Convert Konverter + Optimize Optimaliser + Measure Mål + Redact Sladd + Search results Søkeresultater + No results Ingen resultater + Page {n} of {total} Side {n} av {total} + {n} pages selected {n} sider valgt + · not fixable here · kan ikke rettes her + (page {p}) (side {p}) + (searching… {s} of {p} pages) (søker… {s} av {p} sider) + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} ved {dpi} dpi, den største størrelsen de kan tegnes i) + ({s} not exported: {first}) ({s} ikke eksportert: {first}) + on page {p} på side {p} + · password-protected · passordbeskyttet + (none) (ingen) + , Expires: , Utløper: + 1 difference found 1 forskjell funnet + 1 file 1 fil + 1 file done 1 fil ferdig + 1 file needs attention 1 fil trenger oppmerksomhet + 1 instance 1 forekomst + 1 issue 1 problem + 1 mark 1 markering + 1 page 1 side + 1 page selected 1 side valgt + 1 page selected. 1 side valgt. + 180 degrees 180 grader + 9 digit, A letter, O letter or digit, X any character 9 siffer, A bokstav, O bokstav eller siffer, X hvilket som helst tegn + ; 1 page already had text ; 1 side hadde allerede tekst + ; failed: {list} ; mislyktes: {list} + ; {n} pages already had text ; {n} sider hadde allerede tekst + ? of {n} ? av {n} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. Et uavhengig utviklet PDF-program med åpen kildekode, skrevet i Rust. MIT OR Apache-2.0. + Accessibility Checker Tilgjengelighetskontroll + Accessibility Checker Options Alternativer for tilgjengelighetskontroll + Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 og nyere (40-bits RC4) + Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 og nyere (128-bits RC4) + Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 og nyere (128-bits AES) + Acrobat X and later (256-bit AES) Acrobat X og nyere (256-bits AES) + Action name: Handlingsnavn: + Actions Handlinger + Active Aktiv + Actual Size Faktisk størrelse + Actual text Faktisk tekst + Add Legg til + Add Step Legg til trinn + Add a comment Legg til en kommentar + Add a reply Legg til et svar + Add a sticky note here Legg til en notatlapp her + Add an Action Legg til en handling + Add at least two files Legg til minst to filer + Add files… Legg til filer… + Add folder Legg til mappe + Add folder and subfolders Legg til mappe og undermapper + Add folder and subfolders… Legg til mappe og undermapper… + Add folder… Legg til mappe… + Add open documents Legg til åpne dokumenter + Add the choices people pick from. Legg til valgene folk kan velge mellom. + Add the files to combine Legg til filene som skal slås sammen + Add the steps this action runs, in order. Legg til trinnene denne handlingen kjører, i rekkefølge. + Add to Trusted Certificates Legg til i klarerte sertifikater + Add to trusted certificates Legg til i klarerte sertifikater + Added 1 comment La til 1 kommentar + Added more than once Lagt til mer enn én gang + Added {n} comments La til {n} kommentarer + Adds invisible text over each word; the page image is not changed Legger usynlig tekst over hvert ord; sidebildet endres ikke + Advanced Avansert + Advanced Options {chevron} Avanserte alternativer {chevron} + Align Juster + Align left Venstrejuster + Align right Høyrejuster + Alignment: Justering: + All Alle + All document contents Alt dokumentinnhold + All document contents except metadata Alt dokumentinnhold unntatt metadata + All open documents Alle åpne dokumenter + All pages Alle sider + All pages in document Alle sider i dokumentet + All pages in range Alle sider i området + Allow Rich Text Formatting Tillat rik tekstformatering + Allow user to enter custom text Tillat brukeren å skrive inn egen tekst + Also recognize pages that already have text Gjenkjenn også sider som allerede har tekst + Alternate text Alternativ tekst + An action with this name already exists. En handling med dette navnet finnes allerede. + An open-source PDF workbench — local, private, and scriptable. En PDF-arbeidsbenk med åpen kildekode – lokal, privat og skriptbar. + Angle Vinkel + Animation effects Animasjonseffekter + Annotations, form fill-in, and digital signatures Merknader, skjemautfylling og digitale signaturer + Any Alle + Any except extracting pages Alle unntatt uttrekking av sider + Anyone opening the document will need this password. It's applied when you save. Alle som åpner dokumentet, trenger dette passordet. Det tas i bruk når du lagrer. + Appear behind page Vis bak siden + Appear on top of page Vis over siden + Appearance mode Utseendemodus + Appearance settings… Utseendeinnstillinger… + Applies to 1 page Gjelder 1 side + Applies to {n} pages Gjelder {n} sider + Apply redactions Bruk sladdinger + Apply scale Bruk skala + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Spør GitHub om den nyeste utgivelsen. Ingenting lastes ned eller installeres automatisk. + At most Høyst + Attachments Vedlegg + Attachments Panel and Page Vedleggspanel og side + Audit space usage… Revider plassbruk… + Author Forfatter + Auto Auto + Auto follows the system's text size. Pages always render at the display's full resolution. Auto følger systemets tekststørrelse. Sider gjengis alltid i skjermens fulle oppløsning. + Auto portrait/landscape Automatisk stående/liggende + Auto-rotate pages Roter sider automatisk + Auto-size text to fit redaction region Tilpass tekststørrelsen til sladdeområdet + Available Tilgjengelig + Back side only Bare baksiden + Back to all tools Tilbake til alle verktøy + Back to the document Tilbake til dokumentet + Before each selected page (select pages in Organize) Før hver valgte side (velg sider i Organiser) + Before the number Før nummeret + Bicubic downsampling to Bikubisk nedsampling til + Binding Innbinding + Binding: Innbinding: + Black and white Svart-hvitt + Bold Fet + Booklet Hefte + Booklet subset: Heftedelsett: + Bookmark options Bokmerkealternativer + Bookmarks Panel and Page Bokmerkepanel og side + Border Color: Kantlinjefarge: + Borders and Colors Kantlinjer og farger + Both sides Begge sider + Bottom Bunn + Bottom: Bunn: + Box Boks + Browser's print dialog Nettleserens utskriftsdialog + Butt Flat + Button is checked by default Knappen er avkrysset som standard + Buttons with the same name and value are selected in unison Knapper med samme navn og verdi velges samtidig + Bytes Byte + Calibrate from two points Kalibrer fra to punkter + Can't be read: {e} Kan ikke leses: {e} + Cancelling… Avbryter… + Case-sensitive Skill mellom store og små bokstaver + Category: Kategori: + Center Midtstill + Center window on screen Midtstill vinduet på skjermen + Centre Midten + Certify Sertifiser + Change settings… Endre innstillinger… + Change the colour of line art as well as text Endre fargen på strektegninger i tillegg til tekst + Change {what} Endre {what} + Changes allowed Tillatte endringer + Character limit 0 means no limit. A comb of characters needs a limit. Tegngrense 0 betyr ingen grense. En tegnkam krever en grense. + Character spacing Tegnavstand + Check Again Sjekk igjen + Check again Sjekk igjen + Check box is checked by default Avkrysningsboksen er avkrysset som standard + Check for updates Se etter oppdateringer + Check spelling Stavekontroll + Check the pages Kontroller sidene + Check the pages of {name}: {why} Kontroller sidene i {name}: {why} + Checking Options ({on} of 32 in all categories) Kontrollalternativer ({on} av 32 i alle kategorier) + Checking for a newer version… Ser etter en nyere versjon… + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Velg et PNG- eller JPEG-bilde (opptil 4 MiB). Gjennomsiktighet bevares. + Choose a folder for the exported files Velg en mappe for de eksporterte filene + Choose a folder for the extracted pages Velg en mappe for de uttrukne sidene + Choose a folder for the results Velg en mappe for resultatene + Choose a folder for the searchable files Velg en mappe for de søkbare filene + Choose a layout: Velg et oppsett: + Choose a stamp, then click on the page to place it. Velg et stempel, og klikk deretter på siden for å plassere det. + Choose an image Velg et bilde + Choose images Velg bilder + Choose image… Velg bilde… + Choose photo… Velg foto… + Choose the digital ID that you want to use for signing: Velg den digitale ID-en du vil signere med: + Circle Sirkel + Clean Up Rydd opp + Clean-up Opprydding + Clear All Tøm alt + Clear all Tøm alt + Clear selection / close find Fjern utvalg / lukk søk + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. Klikk på siden for å legge til tekst; hvert klikk starter en ny boks, og ✓ fullfører. Dra elementer for å flytte dem, dra et hjørne for å endre størrelse, dobbeltklikk på tekst for å redigere den. + Click the first point to begin. Klikk på det første punktet for å begynne. + Click to sort, drag to move the column. Drag its edge to resize it. Klikk for å sortere, dra for å flytte kolonnen. Dra kanten for å endre bredden. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Klikk på hjørnepunktene. Enter eller dobbeltklikk fullfører en omkrets eller et areal. Klikk på det første punktet for å lukke et areal. + Clockwise 90 degrees 90 grader med klokka + Close (Esc) Lukk (Esc) + Close Tab Lukk fane + Close panel Lukk panel + Closed arrow Lukket pil + Collapse Skjul + Collapse all Skjul alle + Collapse all bookmarks Skjul alle bokmerker + Collapse tag Skjul tagg + Collate Sorter + Color: Farge: + Colour Farge + Colours are remapped while rendering; the document itself never changes. Fargene omdefineres under gjengivelsen; selve dokumentet endres aldri. + Comb of Kam på + Combine the rest Slå sammen resten + Combined {n} files Slo sammen {n} filer + Coming in milestone Kommer i milepæl + Comment and form data Kommentar- og skjemadata + Commenting, filling in form fields, and signing existing signature fields Kommentering, utfylling av skjemafelt og signering av eksisterende signaturfelt + Comments & Forms Kommentarer og skjemaer + Comments only Bare kommentarer + Commit selected value immediately Bekreft valgt verdi umiddelbart + Common Properties Felles egenskaper + Compare Files Sammenlign filer + Compare this file with an older version: Compare files… in All tools. Sammenlign denne filen med en eldre versjon: Sammenlign filer… under Alle verktøy. + Compatibility Kompatibilitet + Compress document structure (object streams) Komprimer dokumentstrukturen (objektstrømmer) + Compression Komprimering + Configure New Digital ID Sett opp ny digital ID + Remove "{name}" from this list? The file itself is not deleted. Fjerne «{name}» fra denne listen? Selve filen slettes ikke. + Confirm Password Bekreft passord + Conformance level: Samsvarsnivå: + Continue Fortsett + Copies, two-sided printing and colour are chosen in the browser's print dialog. Antall kopier, tosidig utskrift og farge velges i nettleserens utskriftsdialog. + Copies: Antall kopier: + Copy selected text Kopier merket tekst + Copy text Kopier tekst + Couldn't check for updates: {e} Kunne ikke se etter oppdateringer: {e} + Couldn't open the printer's preferences: {e} Kunne ikke åpne skriverinnstillingene: {e} + Counterclockwise 90 degrees 90 grader mot klokka + Country/Region Land/region + Create Custom Stamp Lag egendefinert stempel + Create Link Lag lenke + Create PDF Comment Summary Lag PDF-kommentarsammendrag + Create PDF comment summary… Lag PDF-kommentarsammendrag… + Create PDF from images Lag PDF fra bilder + Create a new Digital ID (self-signed, saved to a password-protected file) Lag en ny digital ID (selvsignert, lagret i en passordbeskyttet fil) + Create accessibility report Lag tilgjengelighetsrapport + Create custom stamp… Lag egendefinert stempel… + Create {what} Lag {what} + Created a PDF from {n} files Laget en PDF fra {n} filer + Created a PDF from {n} files; left out: {names} Laget en PDF fra {n} filer; utelatt: {names} + Creates 1 file from {n} pages. Lager 1 fil av {n} sider. + Creates {f} files from {n} pages. Lager {f} filer av {n} sider. + Crop (% trimmed from each side) Beskjær (% fjernet fra hver side) + Currency symbol: Valutasymbol: + Current page Gjeldende side + Current page ({n}) Gjeldende side ({n}) + Current page — type a page number or label (such as iv) and press Enter Gjeldende side – skriv inn et sidetall eller en sideetikett (som iv) og trykk Enter + Zoom — type a percentage and press Enter Zoom – skriv inn en prosentverdi og trykk Enter + Custom Egendefinert + Custom scale: Egendefinert skala: + Custom text: Egendefinert tekst: + Custom: Egendefinert: + Cut and stack Klipp og stable + Cut marks Skjæremerker + DPI sets the printed page size without resampling the image. DPI angir den utskrevne sidestørrelsen uten å resample bildet. + Dark theme Mørkt tema + Date format Datoformat + Decimal places: Desimaler: + Decimals Desimaler + Declared conformance: {shown} Oppgitt samsvar: {shown} + Decorative figure Dekorativ figur + Default Standard + Default Value: Standardverdi: + Default workspace mode Standard arbeidsområdemodus + Default: Standard: + Delete page {label} Slett side {label} + Delete pages (Delete) Slett sider (Delete) + Delete pages after extracting Slett sidene etter uttrekking + Delete selected pages (Organize) Slett valgte sider (Organiser) + Delete stamp Slett stempel + Delete {o} Slett {o} + Describe the figure Beskriv figuren + Description Beskrivelse + Description: Beskrivelse: + Details Detaljer + Diamond Rombe + Digital ID Digital ID + Digital ID password Passord for digital ID + Direction: Retning: + Discard Forkast + Discard Objects Forkast objekter + Discard User Data Forkast brukerdata + Discard alternate images Forkast alternative bilder + Discard document tags Forkast dokumenttagger + Discard embedded page thumbnails Forkast innebygde sideminiatyrer + Discard embedded print settings Forkast innebygde utskriftsinnstillinger + Discard this text (Esc) Forkast denne teksten (Esc) + Dismiss Lukk + Distinguished name Entydig navn + Distribute Fordel + Document Dokument + Document Properties Dokumentegenskaper + Document Title Dokumenttittel + Document and markups Dokument og markeringer + Document and stamps Dokument og stempler + Document language Dokumentspråk + Document properties ({key}) Dokumentegenskaper ({key}) + Documents and view Dokumenter og visning + Done Ferdig + Done adding text Ferdig med å legge til tekst + Download Last ned + Downsample to Nedsample til + Drag the selection to move it. Esc clears the selection. Dra utvalget for å flytte det. Esc fjerner utvalget. + Drag to reorder Dra for å endre rekkefølge + Draw Tegn + Draw your {what} below. Tegn {what} nedenfor. + Drawing scale changes. Use Actual size for full-scale plans. Tegningsskalaen endres. Bruk Faktisk størrelse for planer i full skala. + Drop PDFs, images, text files or folders here, or choose them. Slipp PDF-er, bilder, tekstfiler eller mapper her, eller velg dem. + Duplicate "{name}" onto: Dupliser «{name}» til: + Duplicate Field Dupliser felt + Duplicate… Dupliser… + Dynamic Dynamisk + Dynamic XFA form laid out from its template: {pages}, {fields}. Dynamisk XFA-skjema satt opp fra malen: {pages}, {fields}. + Each file holds as many of the {n} pages as fit. Hver fil inneholder så mange av de {n} sidene som får plass. + Each image is measured where pages draw it; an image is replaced only if the result is smaller. Hvert bilde måles der sidene tegner det; et bilde erstattes bare hvis resultatet blir mindre. + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. Hvert bilde én gang, navngitt etter dokument og side. JPEG-bilder lagres uendret; andre som PNG. + Earlier in tab order Tidligere i tabulatorrekkefølgen + Edit Action Rediger handling + Edit image Rediger bilde + Edit text… Rediger tekst… + Edit… Rediger… + Email Address E-postadresse + Enable Acrobat JavaScript Slå på Acrobat JavaScript + Enable copying of text, images, and other content Tillat kopiering av tekst, bilder og annet innhold + Enable text access for screen reader devices for the visually impaired Tillat teksttilgang for skjermlesere for synshemmede + Encrypt Krypter + Endpoints Endepunkter + Enhance page Forbedre side + Enter the password that opens it Skriv inn passordet som åpner den + Enter the permissions password to allow combining Skriv inn tillatelsespassordet for å tillate sammenslåing + Entire page Hele siden + Even Pages Only Bare partallssider + Even and Odd Pages Partalls- og oddetallssider + Even pages only Bare partallssider + Every PDF, image and text file in the folder Alle PDF-, bilde- og tekstfiler i mappen + Every PDF, image and text file in the folder and the folders inside it Alle PDF-, bilde- og tekstfiler i mappen og mappene inni den + Example: {sample} Eksempel: {sample} + Exclude images smaller than Utelat bilder mindre enn + Expand Utvid + Expand all Utvid alle + Expand all bookmarks Utvid alle bokmerker + Expand tag Utvid tagg + Expand top-level bookmarks Utvid bokmerker på øverste nivå + Explain Forklar + Export All Images Eksporter alle bilder + Export Value: {on} Eksportverdi: {on} + Export all to data file… Eksporter alt til datafil… + Export certificate… Eksporter sertifikat… + Export comments Eksporter kommentarer + Export form data Eksporter skjemadata + Export stopped: {e} Eksporten stoppet: {e} + Export to Image Eksporter til bilde + Export to Text Eksporter til tekst + Exported 1 image Eksporterte 1 bilde + Exported the text of 1 page Eksporterte teksten fra 1 side + Exported the text of {n} pages Eksporterte teksten fra {n} sider + Exported {n} images Eksporterte {n} bilder + Exporting… {d} of {t} Eksporterer… {d} av {t} + Export… Eksporter… + Extract pages as separate files Trekk ut sider som separate filer + Extract pages to a new document Trekk ut sider til et nytt dokument + Extracted 1 page Trakk ut 1 side + Extracted {n} pages Trakk ut {n} sider + Field is used for file selection Feltet brukes til filvalg + Field names (comma-separated) Feltnavn (kommaseparert) + Field names with + - * / and parentheses; put \\ before spaces in names. Feltnavn med + - * / og parenteser; sett \\ foran mellomrom i navn. + Field value is in range: Feltverdien er i området: + Field value is not validated Feltverdien valideres ikke + Field: {field} on page {p} Felt: {field} på side {p} + Fields to reset (comma-separated; empty: all) Felt som skal tilbakestilles (kommaseparert; tomt: alle) + Figure {i} of {n}{page} Figur {i} av {n}{page} + File (.p12, .pfx) Fil (.p12, .pfx) + File Name Filnavn + File size Filstørrelse + Files are combined from top to bottom. Leave Pages empty to take every page. Filene slås sammen fra topp til bunn. La Sider stå tomt for å ta med alle sidene. + Files are combined in the order shown. Drag them to change it. Filene slås sammen i rekkefølgen som vises. Dra dem for å endre den. + Files for {name} Filer for {name} + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. Filer du åpner i PdfCraft vises her. Slipp en PDF hvor som helst for å åpne den. + Fill & Sign dates Fyll ut og signer-datoer + Fill & Sign signature Fyll ut og signer-signatur + Fill Color: Fyllfarge: + Fill colour Fyllfarge + Fill colour: none Fyllfarge: ingen + Filling in form fields and signing existing signature fields Utfylling av skjemafelt og signering av eksisterende signaturfelt + Filter comments Filtrer kommentarer + Find a tag Finn en tagg + Find options Søkealternativer + Find text Finn tekst + Find tools and commands Finn verktøy og kommandoer + Finding document fonts… Finner dokumentskrifter… + Finish or discard the text being edited before selecting several objects. Fullfør eller forkast teksten som redigeres før du merker flere objekter. + First / last page Første/siste side + Fit Tilpass + Fit Height Tilpass til høyde + Fit Page Tilpass til side + Fit Visible Tilpass til synlig + Fit Width Tilpass til bredde + Fit to height Tilpass til høyde + Fit to page Tilpass til side + Fix Fiks + Fixed what could be fixed; 1 problem remains Rettet det som kunne rettes; 1 problem gjenstår + Fixed what could be fixed; {n} problems remain Rettet det som kunne rettes; {n} problemer gjenstår + Flatten Fill & Sign when saving Flat ut Fyll ut og signer ved lagring + Flip Horizontal Vend horisontalt + Flip Vertical Vend vertikalt + Flip horizontal Vend horisontalt + Flip on long edge Vend langs langsiden + Flip on short edge Vend langs kortsiden + Flip vertical Vend vertikalt + Folders Mapper + Font Size: Skriftstørrelse: + Font colour: Skriftfarge: + Font size 0 uses automatic sizing. Skriftstørrelse 0 gir automatisk størrelse. + Font size: Skriftstørrelse: + Font: Skrift: + Font: Helvetica Skrift: Helvetica + Fonts Skrifter + For page sizes and margins. For sidestørrelser og marger. + Form data and PDF forms Skjemadata og PDF-skjemaer + Form fields are flattened: their values stay visible. Saving rewrites the whole file. Skjemafelt flates ut: verdiene forblir synlige. Lagring skriver hele filen på nytt. + Form fields only Bare skjemafelt + Form fill-in and digital signatures Skjemautfylling og digitale signaturer + Format Format + Format text Formater tekst + Format: Format: + From Fra + From a PDF page or an image Fra en PDF-side eller et bilde + From colour Fra farge + From {file} Fra {file} + From: Fra: + Front side only Bare forsiden + Get help, share feedback and follow development on Discord. Få hjelp, gi tilbakemelding og følg utviklingen på Discord. + Go to a page view Gå til en sidevisning + Go to page {p} Gå til side {p} + Go to the page Gå til siden + Go to this result Gå til dette treffet + Graphic Grafikk + Greyscale Gråtoner + Grid view Rutenettvisning + Hand (H) Hånd (H) + Has JavaScript actions (run in M6) Har JavaScript-handlinger (kjøres i M6) + Height: Høyde: + Hide field highlights Skjul feltuthevinger + Hide layer Skjul lag + Hide menu bar Skjul menylinjen + Hide password Skjul passord + Hide toolbars Skjul verktøylinjer + Hide window controls Skjul vinduskontroller + High Resolution Høy oppløsning + High resolution Høy oppløsning + Highlight Style: Uthevingsstil: + Highlight fields Uthev felt + Home Hjem + Horizontal Horisontal + Horizontal reversed Horisontal omvendt + Horizontal scale Horisontal skalering + Icon Ikon + Images and text Bilder og tekst + Images and text (converted to PDF) Bilder og tekst (konvertert til PDF) + Import list… Importer liste… + Includes unsaved changes Inkluderer ulagrede endringer + Indent Innrykk + Initial View Startvisning + Ink sensitivity Blekkfølsomhet + Insert Date Sett inn dato + Insert Page Number Sett inn sidetall + Insert a blank page after the selection Sett inn en tom side etter utvalget + Insert a file at the end Sett inn en fil på slutten + Insert a file before page {label} Sett inn en fil før side {label} + Insert pages from a file… Sett inn sider fra en fil… + Inserting, deleting, and rotating pages Innsetting, sletting og rotering av sider + Installed fonts… Installerte skrifter… + Intersections Skjæringspunkter + Invisible Rectangle Usynlig rektangel + Issued by: Utstedt av: + Italic Kursiv + Item List Elementliste + Item: Element: + Items marked Ready work today. Elementer merket Klar fungerer i dag. + Its security settings don't allow copying pages Sikkerhetsinnstillingene tillater ikke kopiering av sider + JavaScript Console JavaScript-konsoll + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript er slått av (Innstillinger > JavaScript). + JavaScript, e.g. getField("total").value JavaScript, f.eks. getField("total").value + Join our Discord Bli med på Discord-serveren vår + Join the ArtCraft community Bli med i ArtCraft-fellesskapet + Justify Blokkjuster + Keep source fonts Behold kildeskrifter + Keep tool selected Behold verktøyet valgt + Keep tool selected: on Behold verktøyet valgt: på + Key Algorithm Nøkkelalgoritme + Keychain · Nøkkelring · + Label Etikett + Labels Etiketter + Labels: {preview}. Later pages keep their labels. Etiketter: {preview}. Senere sider beholder etikettene sine. + Landscape Liggende + Landscape Pages Liggende sider + Landscape and Portrait Pages Liggende og stående sider + Larger pages Større sider + Later Senere + Later in tab order Senere i tabulatorrekkefølgen + Layers Lag + Layers Panel and Page Lagpanel og side + Layout and Magnification Oppsett og forstørrelse + Leave out files that can't be read? Vil du utelate filer som ikke kan leses? + Left Venstre + Left Edge Venstre kant + Left out: {names} Utelatt: {names} + Left: Venstre: + Light theme Lyst tema + Limit of Grense på + Line Style: Linjestil: + Line Thickness: Linjetykkelse: + Line ending Linjeende + Line spacing Linjeavstand + Line thickness Linjetykkelse + Link Action Lenkehandling + Link Appearance Lenkeutseende + Link Properties Lenkeegenskaper + Link Type: Lenketype: + List view Listevisning + Load font… Last inn skrift… + Locked Låst + Looking for PDFs… Ser etter PDF-er… + Looking for printers… Ser etter skrivere… + Low Resolution (150 dpi) Lav oppløsning (150 dpi) + Low resolution Lav oppløsning + Magnification Forstørrelse + Make Current Properties Default Gjør gjeldende egenskaper til standard + Margins Marger + Mark Page Range Merk sideområde + Mark as comments Merk som kommentarer + Mark with checkmark Merk med hake + Marked Merket + Mask: Maske: + Match page at 100% Vis siden i 100 % + Medium Middels + Midpoints Midtpunkter + Mixed Blandet + Modified Endret + More options Flere alternativer + More options: Flere alternativer: + More ways to add files Flere måter å legge til filer på + Mouse wheel scroll speed Rullehastighet for musehjul + Move down Flytt ned + Move earlier Flytt tidligere + Move later Flytt senere + Move up Flytt opp + Multi-line Flerlinjet + Multiple Flere + Multiple selection Flervalg + Multiple words or phrases Flere ord eller fraser + Name: Navn: + Navigation tab Navigasjonsfane + Needs manual check Må sjekkes manuelt + Negative number style: Stil for negative tall: + New Action Ny handling + New Action… Ny handling… + New bookmark ({key}) Nytt bokmerke ({key}) + New file: Ny fil: + Next ({key}) Neste ({key}) + Next / previous match Neste/forrige treff + Next figure Neste figur + Next month Neste måned + Next page Neste side + Next sheet Neste ark + Next view Neste visning + No PDFs in this folder yet Ingen PDF-er i denne mappen ennå + No actions Ingen handlinger + No changes allowed Ingen endringer tillatt + No changes to save Ingen endringer å lagre + No check has run yet. Ingen kontroll er kjørt ennå. + No color Ingen farge + No colour Ingen farge + No comments match. Ingen kommentarer samsvarer. + No comments yet. Ingen kommentarer ennå. + No differences in the text. Ingen forskjeller i teksten. + No digital IDs yet. Ingen digitale ID-er ennå. + No documents are open Ingen dokumenter er åpne + No file chosen Ingen fil valgt + No fill Ingen fyll + No fonts are referenced by the pages. Ingen skrifter refereres av sidene. + No issues found Ingen problemer funnet + No limit Ingen grense + No matches Ingen treff + No matches. Ingen treff. + No matching tools Ingen samsvarende verktøy + No openly licensed document fonts found. Load a font file. Fant ingen dokumentskrifter med åpen lisens. Last inn en skriftfil. + No pages selected Ingen sider valgt + No photo chosen Ingen foto valgt + No preview Ingen forhåndsvisning + No problems found: the document conforms to {level}. Ingen problemer funnet: dokumentet samsvarer med {level}. + No security Ingen sikkerhet + No sheets Ingen ark + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. Fant ingen signatur i rammen. Flytt rammen over signaturen eller øk blekkfølsomheten. + No status has been set. Ingen status er angitt. + None (prefix only) Ingen (bare prefiks) + None found Ingen funnet + None — everything is allowed Ingen — alt er tillatt + Not allowed Ikke tillatt + Not present Ikke til stede + Nothing to redo Ingenting å gjøre om + Nothing to undo Ingenting å angre + Number Tall + Number of pages Antall sider + Number pages Nummerer sider + Odd Pages Only Bare oddetallssider + Odd pages only Bare oddetallssider + Offset from centre Forskyvning fra midten + Old file: Gammel fil: + One word or phrase per line Ett ord eller én frase per linje + One {f} file per page, named after the document. Én {f}-fil per side, oppkalt etter dokumentet. + Only change the colour of black text or line art Endre bare fargen på svart tekst eller strektegninger + Only checked properties will change. Mixed means the selected fields have different values. Bare avkryssede egenskaper endres. Blandet betyr at de valgte feltene har ulike verdier. + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. Bare sideinnholdet endres: lenker, kommentarer, skjemafelt og bokmerker på de opprinnelige sidene beholdes. + Open Åpne + Open a PDF ({key}) Åpne en PDF ({key}) + Open a PDF to measure it. Åpne en PDF for å måle den. + Open a document first Åpne et dokument først + Open a web page Åpne en nettside + Open arrow Åpen pil + Open email app Åpne e-postprogram + Open in Full Screen mode Åpne i fullskjermmodus + Open in a new tab Åpne i ny fane + Open link Åpne lenke + Open the file as it was saved then Åpne filen slik den ble lagret da + Open the older version first Åpne den eldre versjonen først + Open this web page? Åpne denne nettsiden? + Open to page Åpne på side + Opened, but its security settings don't allow combining. Enter the permissions password. Åpnet, men sikkerhetsinnstillingene tillater ikke sammenslåing. Skriv inn tillatelsespassordet. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. Åpner innstillingene til skriverdriveren. De lagres for alle utskrifter fra denne datamaskinen; antall kopier, tosidig, gråtoner og papir valgt her gjelder fortsatt. + Optimizing… cleaning up Optimaliserer… rydder opp + Optimizing… image {d} of {t} Optimaliserer… bilde {d} av {t} + Optimizing… merging identical objects Optimaliserer… slår sammen identiske objekter + Optimizing… removing user data Optimaliserer… fjerner brukerdata + Optimizing… writing the copy Optimaliserer… skriver kopien + Optional: needs an AI provider you configure Valgfritt: krever en KI-leverandør du setter opp + Options… Alternativer… + Organization Name Organisasjonsnavn + Organizational Unit Organisasjonsenhet + Orientation: Retning: + Original Original + Original: replace pages Original: erstatt sider + Other colour… Annen farge… + Outdent Reduser innrykk + Output Utdata + Output appears here. Utdata vises her. + Output intent: {list} Utdataformål: {list} + Overlap: Overlapping: + Owner password (no restrictions apply) Eierpassord (ingen begrensninger gjelder) + PDF Optimizer PDF-optimalisering + PDF or image PDF eller bilde + PDF page: {size} PDF-side: {size} + PDF scale: {scale}% PDF-skala: {scale}% + PDF, images and text PDF, bilder og tekst + Page Only Bare siden + Page Range Options Alternativer for sideområde + Page Sizing & Handling Sidestørrelse og håndtering + Page background Sidebakgrunn + Page display: {layout} Sidevisning: {layout} + Page layout Sideoppsett + Page navigation Sidenavigering + Page number Sidetall + Page order: Siderekkefølge: + Page size Sidestørrelse + Page {label} Side {label} + Page {p} of {n} Side {p} av {n} + Pages Panel and Page Sider-panelet og siden + Pages from Sider fra + Pages of {name} to combine, e.g. 1-3, 6 Sider i {name} som skal slås sammen, f.eks. 1-3, 6 + Pages per file Sider per fil + Pages per sheet: Sider per ark: + Pages to Print Sider som skal skrives ut + Pages: Sider: + Pages: {k} of {n} Sider: {k} av {n} + Paper: Papir: + Part of Del av + Password Passord + Password required Passord kreves + Password security Passordsikkerhet + Password-protected Passordbeskyttet + Paste after Lim inn etter + Paths Baner + Pattern Mønster + Patterns Mønstre + Patterns (graphics objects) Mønstre (grafikkobjekter) + PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft ble ikke avsluttet normalt. Disse dokumentene hadde endringer som ble autolagret: + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft fungerer uten nett. Ingen telemetri, ingen konto og ingen skybehandling med mindre du legger til en leverandør. + PdfCraft {c} is up to date. PdfCraft {c} er oppdatert. + PdfCraft {v} is available. PdfCraft {v} er tilgjengelig. + Percentage Prosent + Permissions password Tillatelsespassord + Permitted actions after certifying Tillatte handlinger etter sertifisering + Photo Foto + Pick a tool in the quick bar, or type above. Velg et verktøy i hurtiglinjen, eller skriv ovenfor. + Pictures on those pages are recognized too; words already in the page's text are not added again Bilder på disse sidene gjenkjennes også; ord som allerede finnes i sidens tekst legges ikke til på nytt + Pin a folder to Home Fest en mappe til Hjem + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Fest en mappe for å vise de nyeste PDF-ene i den her, for eksempel der skanneren eller en synkronisert skylagring lagrer dem. + Pin a folder… Fest en mappe… + Plain text in reading order; pages are separated by form feeds. Ren tekst i leserekkefølge; sidene skilles med sideskifttegn. + Planned Planlagt + Planned for milestone Planlagt for milepæl + Points from the page's bottom-left corner. Punkter fra sidens nedre venstre hjørne. + Portrait Stående + Portrait Pages Stående sider + Post Publiser + Poster Plakat + Prefix Prefiks + Preview Forhåndsvisning + Preview unavailable: {e} Forhåndsvisning utilgjengelig: {e} + Preview: page {p} of {n} Forhåndsvisning: side {p} av {n} + Previous ({key}) Forrige ({key}) + Previous / next page Forrige / neste side + Previous figure Forrige figur + Previous month Forrige måned + Previous page Forrige side + Previous sheet Forrige ark + Previous view Forrige visning + Print ({key}) Skriv ut ({key}) + Print in grayscale Skriv ut i gråtoner + Print page border Skriv ut sidekant + Print, NextPage, PrevPage, FirstPage or LastPage Print, NextPage, PrevPage, FirstPage eller LastPage + Printer paper and scaling may differ. Check driver settings. Skriverens papir og skalering kan avvike. Sjekk driverinnstillingene. + Printer properties Skriveregenskaper + Printer: Skriver: + Printing allowed Utskrift tillatt + Privacy Personvern + Properties… Egenskaper… + Protect Using Password Beskytt med passord + Protected: the combined file won't keep its security settings Beskyttet: den sammenslåtte filen beholder ikke sikkerhetsinnstillingene + Quality Kvalitet + Read Only Skrivebeskyttet + Reading colours Lesefarger + Ready Klar + Reason Årsak + Recent Nylige + Recent colours Nylige farger + Recognize Text Gjenkjenn tekst + Recognized 1 word Gjenkjente 1 ord + Recognized {n} words Gjenkjente {n} ord + Recognized {w} words in 1 file Gjenkjente {w} ord i 1 fil + Recognized {w} words in {n} files Gjenkjente {w} ord i {n} filer + Recognizing text… file {d} of {t} Gjenkjenner tekst… fil {d} av {t} + Recognizing text… page {d} of {t} Gjenkjenner tekst… side {d} av {t} + Recommended tools Anbefalte verktøy + Recover Gjenopprett + Recover unsaved documents? Gjenopprette ulagrede dokumenter? + Redact all Sladd alle + Redacted area fill colour: Fyllfarge for sladdet område: + Redaction Tool Properties Egenskaper for sladdeverktøy + Redaction code: Sladdekode: + Reload Last inn på nytt + Reload documents when their files change on disk Last inn dokumenter på nytt når filene endres på disk + Remove checkmark Fjern hake + Remove invalid links and bookmarks Fjern ugyldige lenker og bokmerker + Remove page Fjern side + Remove unreferenced named destinations Fjern navngitte mål uten referanser + Remove unused objects and merge identical ones Fjern ubrukte objekter og slå sammen identiske + Remove {name} Fjern {name} + Remove {name} from Recent Fjern {name} fra nylige + Remove {n} files Fjern {n} filer + Remove {n} pages Fjern {n} sider + Reopen Closed Tab Åpne lukket fane på nytt + Reopen the files that were open when PdfCraft last closed Åpne filene som var åpne da PdfCraft sist ble lukket + Repair log Reparasjonslogg + Repeat overlay text Gjenta overleggstekst + Replace Image… Erstatt bilde… + Replace Pages Erstatt sider + Replace document colours Erstatt dokumentfarger + Replace image Erstatt bilde + Replacement text Erstatningstekst + Replacement: pages of {name} Erstatning: sider fra {name} + Replaces the existing one on those pages. Erstatter den eksisterende på disse sidene. + Report Rapport + Report… Rapport… + Requires user to enter a password for: Krever at brukeren skriver inn et passord for: + Reset columns Tilbakestill kolonner + Reset page size Tilbakestill sidestørrelse + Reset to printer defaults Tilbakestill til skriverens standardverdier + Resize window to initial page Tilpass vinduet til første side + Resolution Oppløsning + Retain existing Behold eksisterende + Reveal in File Explorer Vis i Filutforsker + Reveal in Finder Vis i Finder + Reverse closed arrow Omvendt lukket pil + Reverse open arrow Omvendt åpen pil + Reverse pages Snu siderekkefølgen + Revert changes Forkast endringer + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Gå tilbake til den sist lagrede versjonen av «{name}»? Endringer gjort siden da kan ikke angres etterpå. + Review History Gjennomgangshistorikk + Revisions Revisjoner + Right Høyre + Right Edge Høyre kant + Rotate Clockwise Roter med klokken + Rotate Counterclockwise Roter mot klokken + Rotate Pages Roter sider + Rotate clockwise Roter med klokken + Rotate counterclockwise Roter mot klokken + Rotate view Roter visning + Rotate view clockwise Roter visningen med klokken + Rotate view clockwise ({key}) Roter visningen med klokken ({key}) + Rotate view counterclockwise Roter visningen mot klokken + Rotate: Roter: + Rotation: Rotasjon: + Running action… file {d} of {t} Kjører handling… fil {d} av {t} + Same as interface ({language}) Samme som grensesnittet ({language}) + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. Rensing fjerner skjult informasjon fra dokumentet: metadata, filvedlegg, kommentarer, skjemafelt (flates ut), skjult tekst og lag, bokmerker, lenker, handlinger og skript, og private programdata. + Save & Close Lagre og lukk + Save ({key}) Lagre ({key}) + Save Image As… Lagre bilde som… + Copy Image Kopier bilde + The image has been copied ({w} × {h} pixels) Bildet er kopiert ({w} × {h} piksler) + Couldn't copy the image: {e} Kunne ikke kopiere bildet: {e} + Copy Image in Original Format Kopier bilde i opprinnelig format + The image has been copied as a {ext} file Bildet er kopiert som en {ext}-fil + The page has changed; select the image again Siden er endret; velg bildet på nytt + Save as PDF Lagre som PDF + Save attachment… Lagre vedlegg… + Save changes to “{name}” before closing? Lagre endringene i «{name}» før du lukker? + Save pages Lagre sider + Save tag properties Lagre taggegenskaper + Save the accessibility report Lagre tilgjengelighetsrapporten + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. Lagre dokumentet etterpå: lagring skriver hele filen på nytt, så ingen spor av det fjernede innholdet blir igjen i den. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. Lagre dokumentet etterpå; lagring skriver hele filen på nytt, så ingenting av det fjernede blir igjen i den. + Save the spreadsheet Lagre regnearket + Save these pages as one PDF Lagre disse sidene som én PDF + Saved measurements Lagrede målinger + Saved measurements. Lagret målingene. + Scale relative to target page Skaler i forhold til målsiden + Scan Skann + Script Name: Skriptnavn: + Script: Skript: + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Skript kjører i en sandkasse uten fil- eller nettverkstilgang. Et skript som ber om å åpne en nettside, trenger tillatelse fra deg først. Med JavaScript av fungerer Acrobats standardfunksjoner for formatering, validering og beregning fortsatt. + Scroll long text Rull lang tekst + Scroll tabs left Rull faner til venstre + Scroll tabs right Rull faner til høyre + Search comments Søk i kommentarer + Search tools and commands… Søk etter verktøy og kommandoer… + Searchable Image (Exact) Søkbart bilde (eksakt) + Searching… {s} of {p} pages Søker… {s} av {p} sider + Searching… {s}/{p} Søker… {s}/{p} + Security Sikkerhet + Security settings Sikkerhetsinnstillinger + Select (V) Velg (V) + Select Action: Velg handling: + Select All Merk alt + Select Icon Velg ikon + Select Trigger: Velg utløser: + Select a file first Velg en fil først + Select a file for the stamp Velg en fil til stempelet + Select a password-protected file first Velg en passordbeskyttet fil først + Select a word Velg et ord + Select all pages (Organize) Merk alle sider (Organiser) + Select at most 1000 objects at a time. Velg høyst 1000 objekter om gangen. + Select data files to merge Velg datafiler som skal slås sammen + Select format category: Velg formatkategori: + Select page {label} Velg side {label} + Select pages Velg sider + Select the items to remove from this document. Velg elementene som skal fjernes fra dette dokumentet. + Selected image files: {count} Valgte bildefiler: {count} + Selected pages Valgte sider + Selection Utvalg + Sending to {printer}… Sender til {printer}… + Sensitivity Følsomhet + Sent to {printer} Sendt til {printer} + Sent to {printer}: {msg} Sendt til {printer}: {msg} + Separator style: Skilletegnstil: + Set Alternate Text Angi alternativ tekst + Set Fields to Same Size Gi feltene samme størrelse + Set Page Boxes Angi sidebokser + Set layer visibility Angi lagsynlighet + Set status Angi status + Set to current page ({n}) Sett til gjeldende side ({n}) + Set to zero Sett til null + Shared Field Properties Delte feltegenskaper + Sheet {s} of {n} Ark {s} av {n} + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift låser til 45 grader. Tilbaketast fjerner et punkt. Esc avbryter. + Shift- or Ctrl/Command-click to select several fields. Shift- eller Ctrl/Command-klikk for å velge flere felt. + Shift-click to select several objects. Drag empty space to select an area. Shift-klikk for å velge flere objekter. Dra i tomt område for å velge et område. + Show Report Vis rapport + Show all Vis alle + Show all comments Vis alle kommentarer + Show certificate… Vis sertifikat… + Show layer Vis lag + Show less Vis mindre + Show password Vis passord + Show the accessibility report Vis tilgjengelighetsrapporten + Show this dialog when the Checker starts Vis denne dialogen når kontrollen starter + Show {n} more Vis {n} til + Show: Vis: + Shrink oversized pages Krymp for store sider + Sign Here Signer her + Sign this field… Signer dette feltet… + Sign yourself Signer selv + Signature Details Signaturdetaljer + Signature is INVALID: Signaturen er UGYLDIG: + Signature is valid: Signaturen er gyldig: + Signature panel Signaturpanel + Signature validity is UNKNOWN: Signaturens gyldighet er UKJENT: + Signer's name Underskriverens navn + Simplified field notation: Forenklet feltnotasjon: + Single Page Enkeltside + Single Page Continuous Enkeltside, sammenhengende + Single word or phrase Enkeltord eller frase + Skip Rule Hopp over regel + Skipped Hoppet over + Slash Skråstrek + Smaller pages Mindre sider + Snap to drawing Fest til tegning + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Noen bokmerker vises ikke her: panelet viser et begrenset antall, med begrenset dybde. + Some page content couldn't be read; snapping uses the rest. Noe sideinnhold kunne ikke leses; festing bruker resten. + Sort by Sorter etter + Sort comments by: Sorter kommentarer etter: + Sort items Sorter elementer + Source Kilde + Space Audit Plassrevisjon + Special Spesial + Split document Del opp dokument + Split into files… Del opp i filer… + Square Firkant + Standard Business Standard forretning + Start Start + Start Checking Start kontroll + Start page number Første sidetall + Status Status + Steps Trinn + Strength: {s} Styrke: {s} + Style Stil + Subject Emne + Subset Delsett + Summarize Options Alternativer for sammendrag + Summary Sammendrag + Switch to List view to change the pages it takes. Bytt til listevisning for å endre hvilke sider den tar. + Switch to two-page view first to show the cover page Bytt til tosidevisning først for å vise forsiden + Tab order: move a field with its arrows. Tabulatorrekkefølge: flytt et felt med pilene. + Tag type Taggtype + Tags Tagger + Text Color: Tekstfarge: + Text alignment: Tekstjustering: + Text in images isn't found; recognise text (OCR) first. Tekst i bilder blir ikke funnet; gjenkjenn tekst (OCR) først. + Text is compared word by word; the differences are shown on the new file. Teksten sammenlignes ord for ord; forskjellene vises på den nye filen. + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. Tekstgjenkjenning er ikke installert: modellfilene mangler. Installer PdfCraft på nytt, eller sett PDFCRAFT_MODELS til mappen som inneholder dem. + Text to find Tekst å finne + Text to insert Tekst å sette inn + Text used as a clipping path cannot be moved with this selection. Tekst brukt som klippebane kan ikke flyttes med dette utvalget. + Text, marks and signatures become part of the page. Other comments stay editable. Tekst, markeringer og signaturer blir en del av siden. Andre kommentarer kan fortsatt redigeres. + Thai and Arabic use regular fallback fonts when available. Thai og arabisk bruker vanlige reserveskrifter når de er tilgjengelige. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF-tekstskriftene kan ikke tegne «{c}» ({code}). Velg støttet tekst. + The checker found 1 problem which may prevent the document from being fully accessible. Kontrollen fant 1 problem som kan hindre at dokumentet er fullt tilgjengelig. + The checker found no problems in this document. Kontrollen fant ingen problemer i dette dokumentet. + The checker found {n} problems which may prevent the document from being fully accessible. Kontrollen fant {n} problemer som kan hindre at dokumentet er fullt tilgjengelig. + The document now conforms to {level}; save it to keep the changes Dokumentet samsvarer nå med {level}; lagre det for å beholde endringene + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. Dokumentet åpnes uten passord; dette passordet trengs for å endre begrensningene. Det brukes når du lagrer. + The document's security settings don't allow copying text and images Dokumentets sikkerhetsinnstillinger tillater ikke kopiering av tekst og bilder + The document's security settings don't allow this change Dokumentets sikkerhetsinnstillinger tillater ikke denne endringen + The existing action will be preserved. Den eksisterende handlingen beholdes. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Skriftene PdfCraft skriver med kan ikke tegne «{c}» ({code}), så det vises som ? i alle PDF-visere. Teksten blir lagret. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. Nøkkelen ligger i sertifikatlageret i Windows, som kan be om å tillate at PdfCraft bruker den. + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. Nøkkelen ligger i nøkkelringen i macOS, som kan be om å tillate at PdfCraft bruker den. + The newest {n} of {total} PDFs De nyeste {n} av {total} PDF-er + The password is only used to read the file; it isn't saved. Passordet brukes bare til å lese filen; det lagres ikke. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. Skriverdriverens egne innstillinger for denne utskriften: papirskuff, papirtype, etterbehandling og mer. + The replacement file doesn't have that many pages. Erstatningsfilen har ikke så mange sider. + The same password is tried on each file. Samme passord prøves på hver fil. + The signature will be invisible. Signaturen blir usynlig. + The text is the same Teksten er lik + The value is shown as typed. Verdien vises slik den er skrevet. + There are no redaction marks (mark text, areas or pages first) Det finnes ingen sladdemarkeringer (marker tekst, områder eller sider først) + These files can't be read, so they won't be in the combined PDF: Disse filene kan ikke leses, så de blir ikke med i den sammenslåtte PDF-en: + These objects could not be moved. Select them again and try a smaller move. Disse objektene kunne ikke flyttes. Velg dem på nytt og prøv en mindre flytting. + Thick Tykk + Thickness Tykkelse + Thin Tynn + This area has too many overlapping paths; intersection snapping is limited. Dette området har for mange overlappende baner; festing til skjæringspunkter er begrenset. + This certificate is in your list of trusted certificates. Dette sertifikatet er i listen din over klarerte sertifikater. + This certificate is not trusted. Signatures made with it show an unknown identity. Dette sertifikatet er ikke klarert. Signaturer laget med det viser en ukjent identitet. + This certificate is trusted through a certificate above it in the chain. Dette sertifikatet er klarert gjennom et sertifikat over det i kjeden. + This comment's appearance can't be changed yet. Utseendet til denne kommentaren kan ikke endres ennå. + This dialog shows the details of a certificate and its chain. Denne dialogen viser detaljene for et sertifikat og kjeden dets. + This document contains {n} interactive form fields. Dette dokumentet inneholder {n} interaktive skjemafelt. + This document has no comments to flatten Dette dokumentet har ingen kommentarer å flate ut + This document has no document-level scripts. Dette dokumentet har ingen skript på dokumentnivå. + This document has no editable structure tags. Dette dokumentet har ingen redigerbare strukturtagger. + This document has no form fields to flatten Dette dokumentet har ingen skjemafelt å flate ut + This document has no form fields you can fill in Dette dokumentet har ingen skjemafelt du kan fylle ut + This document has no pages. Dette dokumentet har ingen sider. + This document has no restrictions to lift Dette dokumentet har ingen begrensninger å oppheve + This document has no signatures. Dette dokumentet har ingen signaturer. + This document has no tagged figures. Dette dokumentet har ingen taggede figurer. + This document has no {kind} to change Dette dokumentet har ingen {kind} å endre + This document hasn't been checked yet. Dette dokumentet er ikke kontrollert ennå. + This document is secured. Some changes are restricted by its security settings. Dette dokumentet er sikret. Noen endringer er begrenset av sikkerhetsinnstillingene. + This document isn't password-protected Dette dokumentet er ikke passordbeskyttet + This drawing exceeds the snapping limit; only the first paths are used. Denne tegningen overskrider festegrensen; bare de første banene brukes. + This field is locked. Unlock it to change its properties. Dette feltet er låst. Lås det opp for å endre egenskapene. + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Denne filen er endret på disken. Last den inn på nytt for å se den nye versjonen; ulagrede endringer går tapt. + This file was damaged and has been repaired. Saving keeps the repaired version. Denne filen var skadet og er reparert. Lagring beholder den reparerte versjonen. + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Denne mappen er ikke tilgjengelig akkurat nå. Den kan ha blitt flyttet, eller stasjonen eller skysynkroniseringen er frakoblet. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. Skriptene i dette skjemaet er slått av fordi ett kjørte for lenge. Åpne dokumentet på nytt for å kjøre dem igjen. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. Dette er et dynamisk XFA-skjema, som PdfCraft ikke kan vise ennå. Det du ser er filens plassholderside. + This measurement needs at least {n} points. Denne målingen trenger minst {n} punkter. + This page contains objects that cannot be selected together. Denne siden inneholder objekter som ikke kan velges sammen. + This page couldn't be displayed.\n{e} Denne siden kunne ikke vises.\n{e} + This printer offers no settings of its own. Denne skriveren har ingen egne innstillinger. + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… Driveren til denne skriveren tar ikke imot tosidig-innstillinger fra andre programmer, så dens egen innstilling gjelder. Endre den under Egenskaper… + This web address mixes letters from different alphabets, a common way to imitate another site's name. Denne nettadressen blander bokstaver fra ulike alfabeter, en vanlig måte å etterligne et annet nettsteds navn på. + This web address uses letters from another alphabet, which can look like familiar ones. Denne nettadressen bruker bokstaver fra et annet alfabet, som kan ligne på kjente bokstaver. + Tile scale: Flisskala: + Time Tid + Title Tittel + To change locked fields, also set Locked to No. For å endre låste felt må du også sette Låst til Nei. + To: Til: + Today: {date} I dag: {date} + Toggle fit page / fit width Veksle mellom tilpass til side og tilpass til bredde + Tool Verktøy + Tools panel side Side for verktøypanelet + Tooltip: Verktøytips: + Top Topp + Top-level bookmarks ({n}) Bokmerker på øverste nivå ({n}) + Total Totalt + Trust Stol på + Try the form as people filling it in will see it Prøv skjemaet slik de som fyller det ut vil se det + Two-Up (Cover Page) To sider (forside) + Two-Up (Facing) To sider (motstående) + Two-Up Continuous (Cover Page) To sider, sammenhengende (forside) + Two-Up Continuous (Facing) To sider, sammenhengende (motstående) + Two-sided: Tosidig: + Type Type + Type text Skriv tekst + Type the web address. Skriv inn nettadressen. + Type your {what}. Skriv inn {what}. + URL to submit to URL å sende til + Unit Enhet + Units Enheter + Unknown author Ukjent forfatter + Unlock Lås opp + Unlock {name} Lås opp {name} + Unlock {n} files Lås opp {n} filer + Unlocked: the combined file won't be password-protected Låst opp: den sammenslåtte filen blir ikke passordbeskyttet + Unlock… Lås opp… + Unpin Løsne + Unpin {folder} Løsne {folder} + Unplaced Ikke plassert + Unsupported measurements Målinger som ikke støttes + Up Opp + Update Oppdater + Use 72 DPI (one point per pixel) Bruk 72 DPI (ett punkt per piksel) + Use Flate to encode streams that are not encoded Bruk Flate til å kode strømmer som ikke er kodet + Use a Digital ID from a file Bruk en digital ID fra en fil + Use custom DPI Bruk egendefinert DPI + Use each image's embedded DPI; use 72 DPI when it is absent. Bruk hvert bildes innebygde DPI; bruk 72 DPI når den mangler. + Use image resolution Bruk bildeoppløsning + Use now Bruk nå + Use overlay text Bruk overleggstekst + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Brukes når en PDF ikke ber om et oppsett eller en zoom. Sammenhengende rulling fester aldri mellom sider. + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. Brukes når PDF-er åpnes. En eksplisitt oppstarts- eller kontrollmodus har forrang for økten. + User Interface Options Alternativer for brukergrensesnitt + User password Brukerpassord + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. Gyldig i 5 år, for digitale signaturer. Selvsignerte ID-er er vanligvis ikke klarert av andre. + Validate all Valider alle + Value is not calculated Verdien beregnes ikke + Value is the Verdien er + Value: Verdi: + Verify Verifiser + Verify to see what the document needs. Verifiser for å se hva dokumentet trenger. + Version {v} Versjon {v} + Vertical Loddrett + Vertical reversed Loddrett omvendt + Vertical text cannot be moved with this selection. Loddrett tekst kan ikke flyttes med dette utvalget. + View revision {n} Vis revisjon {n} + View signed version Vis signert versjon + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Du ser på revisjon {rev} av «{from}». Dette er et øyeblikksbilde: endringer her påvirker ikke originalen. + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Du ser på den signerte versjonen av «{from}» (revisjon {rev}). Dette er et øyeblikksbilde: endringer her påvirker ikke originalen. + Viewport Visningsområde + Viewport rectangle in page points (x, y, width, height) Visningsområdets rektangel i sidepunkter (x, y, bredde, høyde) + Visible Rectangle Synlig rektangel + Visual Visuell + Warnings Advarsler + Welcome to PdfCraft Velkommen til PdfCraft + What word or phrase would you like to search for? Hvilket ord eller hvilken frase vil du søke etter? + Whole words only Bare hele ord + Width: Bredde: + Window Options Vindusalternativer + Windows store · Windows-lager · + Write this email? Skrive denne e-posten? + Wrong password Feil passord + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-skjema: feltene og XFA-dataene holdes i takt, så andre visere viser det du fyller inn. + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Du er i ferd med å bruke 1 sladdemarkering. Tekst, bilder og tegninger under markeringene, og kommentarer og skjemafelt som overlapper dem, fjernes permanent. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Du er i ferd med å bruke {n} sladdemarkeringer. Tekst, bilder og tegninger under markeringene, og kommentarer og skjemafelt som overlapper dem, fjernes permanent. + You have version {c}. All releases are listed at {page}. Du har versjon {c}. Alle utgivelser er listet på {page}. + You have version {c}. Download the new version from its release page. Du har versjon {c}. Last ned den nye versjonen fra utgivelsessiden. + Your changes will be lost if you don't save them. Endringene dine går tapt hvis du ikke lagrer dem. + ZIP ZIP + Zoom in Zoom inn + Zoom in ({key}) Zoom inn ({key}) + Zoom in / out (also pinch or {key}-scroll) Zoom inn / ut (også knip eller {key}-rull) + Zoom out Zoom ut + Zoom out ({key}) Zoom ut ({key}) + already a PDF allerede en PDF + and {n} more og {n} til + background bakgrunn + characters tegn + e.g. My stamps f.eks. Mine stempler + e.g. en-US f.eks. nb-NO + for images above for bilder over + header or footer topptekst eller bunntekst + initials initialer + is available er tilgjengelig + is not available yet er ikke tilgjengelig ennå + just now nettopp + yesterday i går + needs an AI provider (off by default) trenger en AI-leverandør (av som standard) + no output folder was chosen ingen utdatamappe ble valgt + no unused output name ikke noe ubrukt utdatanavn + none ingen + of the following fields: av følgende felt: + AI assistant AI-assistent + Action Wizard Handlingsveiviser + Action Wizard… Handlingsveiviser… + Actions list Handlingsliste + Add Bates numbering… Legg til Bates-nummerering… + Add a button Legg til en knapp + Add a callout Legg til en bildeforklaring + Add a checkbox Legg til en avkrysningsboks + Add a date field Legg til et datofelt + Add a digital signature field Legg til et felt for digital signatur + Add a drop-down list Legg til en nedtrekksliste + Add a list box Legg til en listeboks + Add a radio button Legg til en alternativknapp + Add a stamp Legg til et stempel + Add a sticky note Legg til en notatlapp + Add a text box Legg til en tekstboks + Add a text field Legg til et tekstfelt + Add alternate text Legg til alternativ tekst + Add alternate text… Legg til alternativ tekst… + Add an image field Legg til et bildefelt + Add background… Legg til bakgrunn… + Add comments Legg til kommentarer + Add content Legg til innhold + Add files to combine Legg til filer som skal slås sammen + Add form components Legg til skjemakomponenter + Add header & footer… Legg til topptekst og bunntekst… + Add image… Legg til bilde… + Add or edit links Legg til eller rediger lenker + Add printer marks Legg til skrivermerker + Add search index Legg til søkeindeks + Add text Legg til tekst + Add watermark… Legg til vannmerke… + Advanced optimization Avansert optimalisering + Advanced options Avanserte alternativer + Apply PDF standards Bruk PDF-standarder + Apply redactions… Utfør sladding… + Archive documents Arkiver dokumenter + Area Areal + Arrow Pil + ArtCraft website ArtCraft-nettsted + Ask about this document Spør om dette dokumentet + Attach a file Legg ved en fil + Attach file Legg ved fil + Audit space usage Analyser plassbruk + Automatically tag PDF Tagg PDF automatisk + Background Bakgrunn + Bates numbering Bates-nummerering + Blank page Tom side + Bring your own model (off by default) Bruk din egen modell (av som standard) + Button Knapp + Certificates Sertifikater + Certify (invisible signature) Sertifiser (usynlig signatur) + Certify (visible signature) Sertifiser (synlig signatur) + Change initials Endre initialer + Change reading options Endre lesealternativer + Change reading options… Endre lesealternativer… + Change signature Endre signatur + Check for accessibility Sjekk tilgjengelighet + Check for accessibility… Sjekk tilgjengelighet… + Checkbox Avkrysningsboks + Checkmark Hake + Clear form Tøm skjema + Clear redaction marks Fjern sladdemarkeringer + Clipboard Utklippstavle + Cloud Sky + Column select Kolonnemerking + Combine Slå sammen + Combine files Slå sammen filer + Comment list Kommentarliste + Comments panel Kommentarpanel + Compare Sammenlign + Compare files Sammenlign filer + Compare files… Sammenlign filer… + Compress a PDF Komprimer en PDF + Console Konsoll + Convert colors Konverter farger + Copy pages Kopier sider + Correct recognized text Korriger gjenkjent tekst + Create a PDF Opprett en PDF + Create from Opprett fra + Create links from URLs Opprett lenker fra URL-er + Crop pages Beskjær sider + Cross Kryss + Cut pages Klipp ut sider + Dark gray Mørk grå + Date Dato + Date field Datofelt + Detect form fields Oppdag skjemafelt + Digital signature Digital signatur + Digitally sign Signer digitalt + Distance Avstand + Document JavaScripts Dokument-JavaScript + Document JavaScripts… Dokument-JavaScript… + Document actions Dokumenthandlinger + Dot Prikk + Draw a cloud Tegn en sky + Draw a line Tegn en linje + Draw a polygon Tegn en mangekant + Draw a rectangle Tegn et rektangel + Draw an arrow Tegn en pil + Draw an oval Tegn en ellipse + Draw connected lines Tegn sammenhengende linjer + Draw freehand Tegn på frihånd + Drawing Tegning + Drawing scale Tegneskala + Drop-down list Nedtrekksliste + Duplicate pages Dupliser sider + Edit a PDF Rediger en PDF + Edit text & images Rediger tekst og bilder + Encrypt with certificate Krypter med sertifikat + Enhance camera image Forbedre kamerabilde + Enhance scanned file Forbedre skannet fil + Enter permissions password… Skriv inn tillatelsespassord… + Erase drawings Visk ut tegninger + Export a PDF Eksporter en PDF + Export all comments to data file… Eksporter alle kommentarer til datafil… + Export all images Eksporter alle bilder + Export all images… Eksporter alle bilder… + Export data Eksporter data + Export form data… Eksporter skjemadata… + Export measurements Eksporter målinger + Export measurements as CSV Eksporter målinger som CSV + Export to Eksporter til + Export to PowerPoint… Eksporter til PowerPoint… + Extract Trekk ut + Extract pages Trekk ut sider + Field list Feltliste + Field properties Feltegenskaper + Field properties… Feltegenskaper… + Fields Felt + Fill Fyll + Fill & Sign: add text Fyll ut og signer: legg til tekst + Fill & Sign: change initials Fyll ut og signer: endre initialer + Fill & Sign: change signature Fyll ut og signer: endre signatur + Fill & Sign: checkmark Fyll ut og signer: hake + Fill & Sign: cross Fyll ut og signer: kryss + Fill & Sign: date Fyll ut og signer: dato + Fill & Sign: dot Fyll ut og signer: prikk + Fill & Sign: initials Fyll ut og signer: initialer + Fill & Sign: line Fyll ut og signer: linje + Fill & Sign: remove saved initials Fyll ut og signer: fjern lagrede initialer + Fill & Sign: remove saved signature Fyll ut og signer: fjern lagret signatur + Fill & Sign: sign Fyll ut og signer: signer + Find text and redact Finn tekst og sladd + Find text and redact… Finn tekst og sladd… + Fit to width scrolling Rulling tilpasset til bredde + Fit visible Tilpass synlig + Fix hairlines Reparer hårlinjer + Fix reading order Rett opp leserekkefølgen + Flatten comments Flat ut kommentarer + Flatten form fields Flat ut skjemafelt + Form fields panel Skjemafeltpanel + Geospatial location Geografisk plassering + Go to page… Gå til side… + HTML web page HTML-nettside + Header and footer Topptekst og bunntekst + Hide all comments Skjul alle kommentarer + Highlight text Uthev tekst + Image Bilde + Image (PNG) Bilde (PNG) + Image field Bildefelt + Images Bilder + Import comments… Importer kommentarer… + Import data Importer data + Import form data… Importer skjemadata… + In multiple files I flere filer + In this file I denne filen + Index Indeks + Initials Initialer + Insert Sett inn + Insert pages Sett inn sider + Insert text Sett inn tekst + JavaScript JavaScript + JavaScript console… JavaScript-konsoll… + Join the ArtCraft Discord Bli med i ArtCraft-Discorden + Light gray Lys grå + Line Linje + Link Lenke + List box Listeboks + Make accessible Gjør tilgjengelig + Manage embedded index Administrer innebygd indeks + Markup Markering + Marquee zoom Markeringszoom + Measure area Mål areal + Measure distance Mål avstand + Measure objects Mål objekter + Measure perimeter Mål omkrets + Measurement information Målingsinformasjon + Measurement snapping Målingsfesting + Merge data files into spreadsheet… Slå sammen datafiler til regneark… + Microsoft Word (.docx) Microsoft Word (.docx) + Modify page Endre side + Multiple files Flere filer + New bookmarks from structure Nye bokmerker fra struktur + Open accessibility report Åpne tilgjengelighetsrapport + Optimize scanned documents Optimaliser skannede dokumenter + Optimize scanned documents… Optimaliser skannede dokumenter… + Output preview Forhåndsvisning av utdata + Oval Ellipse + PDF/A… PDF/A… + Page grid Siderutenett + Page labels Sideetiketter + Page options Sidealternativer + Page transitions Sideoverganger + Paste pages Lim inn sider + PdfCraft on GitHub PdfCraft på GitHub + PdfCraft web page PdfCraft-nettside + Perimeter Omkrets + Pin folder to Home… Fest mappe til Hjem… + PostScript / EPS PostScript / EPS + Preflight Preflight + Prepare for accessibility Forbered for tilgjengelighet + Prepare for distribution Forbered for distribusjon + Prepare for distribution… Forbered for distribusjon… + Presentation (.pptx) Presentasjon (.pptx) + Print production Trykkproduksjon + Protect a PDF Beskytt en PDF + Protect with password Beskytt med passord + Radio button Alternativknapp + Recognize text Gjenkjenn tekst + Recognize text in multiple files… Gjenkjenn tekst i flere filer… + Recognize text… Gjenkjenn tekst… + Rectangle Rektangel + Redact a PDF Sladd en PDF + Redact pages Sladd sider + Redact pages… Sladd sider… + Redact text and images Sladd tekst og bilder + Redaction properties… Sladdingsegenskaper… + Reduce file size Reduser filstørrelse + Remove all links Fjern alle lenker + Remove background Fjern bakgrunn + Remove header & footer Fjern topptekst og bunntekst + Remove hidden information Fjern skjult informasjon + Remove hidden information… Fjern skjult informasjon… + Remove watermark Fjern vannmerke + Replace Erstatt + Replace text Erstatt tekst + Reset panel layout Tilbakestill paneloppsett + Review Gjennomgang + Rich Text Format (.rtf) Rik tekst-format (.rtf) + Rotate Roter + Rotate pages Roter sider + Rotate pages clockwise Roter sider med klokken + Rotate pages counterclockwise Roter sider mot klokken + Sanitize document Rens dokument + Sanitize document… Rens dokument… + Save as PDF/A Lagre som PDF/A + Save as PDF/UA Lagre som PDF/UA + Save as PDF/X Lagre som PDF/X + Scan & OCR Skann og OCR + Security properties Sikkerhetsegenskaper + Select files to compare Velg filer som skal sammenlignes + Separate PDFs Del opp PDF-er + Set measurement scale Angi målestokk + Set page boxes Angi sidebokser + Set page boxes… Angi sidebokser… + Set properties Angi egenskaper + Show cover page in two-page view Vis forside i tosidevisning + Signature Signatur + Signatures panel Signaturpanel + Single file Én fil + Snapping Festing + Split Del opp + Spreadsheet (.xlsx) Regneark (.xlsx) + Squiggly underline Bølgete understreking + Squiggly underline text Bølgete understreking av tekst + Stamp Stempel + Stamp palette Stempelpalett + Stamps Stempler + Standards Standarder + Sticky note Notatlapp + Strikethrough text Gjennomstreking av tekst + Summarize Oppsummer + Summarize comments Oppsummer kommentarer + Switch light / dark theme Bytt mellom lyst og mørkt tema + Tab order by columns Tabulatorrekkefølge etter kolonner + Tab order by rows Tabulatorrekkefølge etter rader + Tab order: by columns Tabulatorrekkefølge: etter kolonner + Tab order: by document structure Tabulatorrekkefølge: etter dokumentstruktur + Tab order: by rows Tabulatorrekkefølge: etter rader + Tags panel Taggpanel + Text (plain) Tekst (ren) + Text box Tekstboks + Text field Tekstfelt + Timestamp Tidsstempel + Translate Oversett + Underline text Understrek tekst + Update background… Oppdater bakgrunn… + Update header & footer… Oppdater topptekst og bunntekst… + Update watermark… Oppdater vannmerke… + Use JavaScript Bruk JavaScript + Use a certificate Bruk et sertifikat + Use guided actions Bruk veiledede handlinger + Use print production Bruk trykkproduksjon + Use system setting Bruk systeminnstilling + Validate all signatures Valider alle signaturer + Watermark Vannmerke + Move object Flytt objekt + Move objects Flytt objekter + All tools Alle verktøy + Read Les + E-Sign E-signer + Tools Verktøy + Inspector Inspektør + on page {label} på side {label} + A font isn't embedded En skrift er ikke innebygd + A {} action isn't allowed En {}-handling er ikke tillatt + A {} annotation has additional actions En {}-merknad har tilleggshandlinger + A {} annotation has no appearance stream En {}-merknad mangler utseendestrøm + A {} annotation isn't set to print, or is hidden En {}-merknad er ikke satt til å skrives ut, eller er skjult + Accessibility Report Tilgjengelighetsrapport + Add the PDFs to combine Legg til PDF-ene som skal slås sammen + Added the first {n} PDFs in {folder} La til de første {n} PDF-ene i {folder} + CRL Signing CRL-signering + Certificate Signing Sertifikatsignering + Change Author Endre forfatter + Change Keywords Endre nøkkelord + Change Subject Endre emne + Change Title Endre tittel + Choose a digital ID. Velg en digital ID. + Create links Opprett lenker + Creator Opprettet av + Cut and stack needs Two-sided: Off. Print single-sided sheets. Klipp og stable krever Tosidig: Av. Skriv ut ensidige ark. + Data Encipherment Datakryptering + Decipher Only Kun dekryptering + Detailed Report Detaljert rapport + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile DeviceCMYK brukes, men PDF/A-utdataintensjonen er ikke en CMYK-profil + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile DeviceRGB brukes, men PDF/A-utdataintensjonen er ikke en RGB-profil + Digital Signature Digital signatur + Document properties (⌘D) Dokumentegenskaper (⌘D) + Drop PDFs or folders here, or choose them. Slipp PDF-er eller mapper her, eller velg dem. + Encipher Only Kun kryptering + English Engelsk + Enter a URL Skriv inn en URL + Enter a name. Skriv inn et navn. + Enter a page number Skriv inn et sidetall + Enter the field names Skriv inn feltnavnene + Every PDF in the folder Alle PDF-er i mappen + Every PDF in the folder and the folders inside it Alle PDF-er i mappen og mappene i den + Field "{}" on page {p} has no description (tooltip) Feltet «{}» på side {p} mangler beskrivelse (verktøytips) + Filename Filnavn + Flatten Fill & Sign Flat ut Fyll ut og signer + Font (unnamed) on page {p} doesn't map its characters to Unicode Skrift (uten navn) på side {p} tilordner ikke tegnene sine til Unicode + Font {} on page {p} doesn't map its characters to Unicode Skriften {} på side {p} tilordner ikke tegnene sine til Unicode + Form field on page {p} isn't tagged Skjemafelt på side {p} er ikke tagget + H{n} follows an H{d}; headings should not skip levels H{n} følger etter en H{d}; overskrifter bør ikke hoppe over nivåer + Inserted text Innsatt tekst + Key Agreement Nøkkelavtale + Key Encipherment Nøkkelkryptering + Margins (in) Marger (tommer) + New bookmark (⌘B) Nytt bokmerke (⌘B) + Next (⌘G) Neste (⌘G) + No PDFs found in {folder} Fant ingen PDF-er i {folder} + No document language is set Ingen dokumentspråk er angitt + No page has text: the document appears to be images of pages Ingen sider har tekst: dokumentet ser ut til å være bilder av sider + Non-Repudiation Ikke-benekting + Only the document's owner can change its security (open it with the permissions password) Bare dokumentets eier kan endre sikkerheten (åpne det med tillatelsespassordet) + Open a PDF (⌘O) Åpne en PDF (⌘O) + Page {p} doesn't tab in structure order Side {p} tabulerer ikke i strukturrekkefølge + Page {p}: {n} untagged content item Side {p}: {n} utagget innholdselement + Page {p}: {n} untagged content items Side {p}: {n} utaggede innholdselementer + Page {} has additional actions Side {} har tilleggshandlinger + Panel layout could not be restored: {error} Paneloppsettet kunne ikke gjenopprettes: {error} + Panel move was not applied: {error} Panelflyttingen ble ikke utført: {error} + PdfCraft can't check this signature yet: {}. PdfCraft kan ikke kontrollere denne signaturen ennå: {}. + Previous (⇧⌘G) Forrige (⇧⌘G) + Print (⌘P) Skriv ut (⌘P) + Producer Produsent + Redo {action} Gjør om {action} + Rendering… Gjengir… + Report created Rapport opprettet + Rev. {rev}: Certified by an unknown signer Rev. {rev}: Sertifisert av en ukjent undertegner + Rev. {rev}: Certified by {name} Rev. {rev}: Sertifisert av {name} + Rev. {rev}: Signed by an unknown signer Rev. {rev}: Signert av en ukjent undertegner + Rev. {rev}: Signed by {name} Rev. {rev}: Signert av {name} + Rotate view clockwise (⇧⌘+) Roter visningen med klokken (⇧⌘+) + Rule Name Regelnavn + Save (M4) Lagre (M4) + Save as {level} Lagre som {level} + Signature is invalid Signaturen er ugyldig + Signature is valid Signaturen er gyldig + Signature validity is unknown Signaturens gyldighet er ukjent + Signing time is from the clock on the signer's computer. Signeringstidspunktet kommer fra klokken på undertegnerens datamaskin. + Still rendering — this page is unusually complex… Gjengir fortsatt – denne siden er uvanlig kompleks… + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. ECDSA-signaturen er ikke i kanonisk DER (etterfølgende byte, utfylling eller manglende fortegnsbyte); den ble lest likevel. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. ECDSA-signaturen er de rå r- og s-verdiene i stedet for en DER-sekvens; den ble lest likevel. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. RSA-signaturverdien har ikke samme lengde som nøkkelens modulus (innledende nullbyte er fjernet eller lagt til); den ble lest likevel. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. RSA-signaturens DigestInfo mangler NULL-parameter (eldre signeringsprogrammer skriver den slik); den ble lest likevel. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Sertifikatet til {} tillater ikke så mange CA-sertifikater under seg (stilengde), så det brukes ikke til å gå god for {}. + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Sertifikatet til {} er ikke et CA-sertifikat som kan utstede sertifikater, så det brukes ikke til å gå god for {}. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Sertifikatet til {} var ikke gyldig på signeringstidspunktet, så det brukes ikke til å gå god for {}. + The checker found problems which may prevent the document from being fully accessible. Kontrollen fant problemer som kan hindre at dokumentet er fullt tilgjengelig. + The document has been altered or corrupted since the signature was applied. Dokumentet er endret eller skadet etter at signaturen ble påført. + The document has been altered or corrupted since the timestamp was applied. Dokumentet er endret eller skadet etter at tidsstempelet ble påført. + The document has been altered since this signature was applied in ways it does not permit ({}). Dokumentet er endret etter at denne signaturen ble påført, på måter den ikke tillater ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokumentet er endret etter at dette tidsstempelet ble påført, på måter det ikke tillater ({}). + The document has been modified since this signature was applied, but the changes are permitted ({}). Dokumentet er endret etter at denne signaturen ble påført, men endringene er tillatt ({}). + The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokumentet er endret etter at dette tidsstempelet ble påført, men endringene er tillatt ({}). + The document has no tags (structure tree) Dokumentet har ingen tagger (strukturtre) + The document has no title Dokumentet har ingen tittel + The document isn't marked as tagged (MarkInfo Marked) Dokumentet er ikke merket som tagget (MarkInfo Marked) + The document isn't tagged, so none of its content is Dokumentet er ikke tagget, så ingenting av innholdet er det + The document's embedded revocation information shows that the signer's certificate has not been revoked. Dokumentets innebygde tilbakekallingsinformasjon viser at undertegnerens sertifikat ikke er tilbakekalt. + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. Det innebygde tidsstempeltokenet er gyldig og utstederen er klarert; klarert tid er {}. + The font {} isn't embedded Skriften {} er ikke innebygd + The metadata doesn't identify the file as {} Metadataene identifiserer ikke filen som {} + The password is incorrect. Passordet er feil. + The password must have at least 6 characters. Passordet må ha minst 6 tegn. + The passwords do not match. Passordene stemmer ikke overens. + The security settings don't allow screen readers to read the text Sikkerhetsinnstillingene tillater ikke at skjermlesere leser teksten + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. Signaturalgoritmen og SignerInfo-ens sammendragsalgoritme navngir ulike hasher; signaturen ble laget med SignerInfo-ens. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. Signaturen har et uverifisert tidsstempel ({}): tidsstempelutstederen er ikke i listen din over klarerte sertifikater, så signeringstidspunktet undertegneren oppgir brukes i stedet. + The signature contents are not hexadecimal. Signaturinnholdet er ikke heksadesimalt. + The signature could not be checked ({}). Signaturen kunne ikke kontrolleres ({}). + The signature could not be read ({}). Signaturen kunne ikke leses ({}). + The signature could not be verified ({}). Signaturen kunne ikke verifiseres ({}). + The signature has no valid byte range. Signaturen har ikke noe gyldig byteområde. + The signature includes an embedded timestamp. Signaturen inneholder et innebygd tidsstempel. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. Signaturen er BER-kodet (ubestemte lengder) i stedet for DER; den ble lest lempelig. + The signature uses the legacy adbe.x509.rsa_sha1 format. Signaturen bruker det eldre formatet adbe.x509.rsa_sha1. + The signature value does not match the signer's certificate: the signature is corrupt. Signaturverdien stemmer ikke med undertegnerens sertifikat: signaturen er skadet. + The signature's byte range does not exclude exactly its contents. Signaturens byteområde utelater ikke nøyaktig innholdet sitt. + The signature's byte range does not match the file. Signaturens byteområde stemmer ikke med filen. + The signature's timestamp token could not be verified ({}). Signaturens tidsstempeltoken kunne ikke verifiseres ({}). + The signature's timestamp token does not cover the signature value. Signaturens tidsstempeltoken dekker ikke signaturverdien. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. Det signerte innholdet er en konstruert (segmentert) OCTET STRING, slik BER tillater; det ble lest likevel. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). Undertegnerens sertifikat tillater ikke digitale signaturer (nøkkelbruken har verken digital signatur eller ikke-benekting). + The signer's certificate has been revoked ({}). Undertegnerens sertifikat er tilbakekalt ({}). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). Undertegnerens sertifikat har kritiske utvidelser som PdfCraft ikke kjenner igjen ({}). + The signer's certificate is not in the signature. Undertegnerens sertifikat finnes ikke i signaturen. + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Undertegnerens sertifikat er ikke utstedt for signering av dokumenter (den utvidede nøkkelbruken har ikke noe formål for dokumentsignering). + The signer's certificate was not valid at the time of signing. Undertegnerens sertifikat var ikke gyldig på signeringstidspunktet. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. Undertegnerens identitet er ukjent fordi den ikke er tatt med i listen din over klarerte sertifikater, og ingen av de overordnede sertifikatene er klarerte sertifikater. + The signer's identity is valid. Undertegnerens identitet er gyldig. + The timestamp authority's certificate was not valid at the time of timestamping. Tidsstempelutstederens sertifikat var ikke gyldig på tidsstemplingstidspunktet. + The timestamp contents are not hexadecimal. Tidsstempelinnholdet er ikke heksadesimalt. + The timestamp has no valid byte range. Tidsstempelet har ikke noe gyldig byteområde. + The timestamp token could not be read ({}). Tidsstempeltokenet kunne ikke leses ({}). + The timestamp token is valid and its authority is trusted. Tidsstempeltokenet er gyldig og utstederen er klarert. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. Tidsstempeltokenet er gyldig, men tidsstempelutstederen er ikke i listen din over klarerte sertifikater. + The timestamp's byte range does not exclude exactly its contents. Tidsstempelets byteområde utelater ikke nøyaktig innholdet sitt. + The timestamp's byte range does not match the file. Tidsstempelets byteområde stemmer ikke med filen. + The window shows the file name, not the title (Initial View > Show) Vinduet viser filnavnet, ikke tittelen (Startvisning > Vis) + There is no folder to save the digital ID in. Det finnes ingen mappe å lagre den digitale ID-en i. + This document has not been modified since this signature was applied. Dette dokumentet er ikke endret etter at denne signaturen ble påført. + This document has not been modified since this timestamp was applied. Dette dokumentet er ikke endret etter at dette tidsstempelet ble påført. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. Denne signaturen bruker RIPEMD-160, en eldre 160-bits hashalgoritme som ikke lenger anbefales for signaturer. Den validerer fortsatt, men bør ikke stoles på. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. Denne signaturen bruker SHA-1, en svak hashalgoritme: kollisjoner i den er påvist. Den validerer fortsatt fordi mange dokumenter signert for flere år siden bruker den, men bør ikke stoles på. + Undo {action} Angre {action} + Unsigned signature field Usignert signaturfelt + Unsigned signature field: {name} Usignert signaturfelt: {name} + Zoom in (⌘+) Zoom inn (⌘+) + Zoom in / out (also pinch or ⌘-scroll) Zoom inn / ut (også knip eller ⌘-rulling) + Zoom out (⌘−) Zoom ut (⌘−) + alternate text hides an annotation inside it alternativ tekst skjuler en merknad inni seg + alternate text on an element with no page content alternativ tekst på et element uten sideinnhold + comments kommentarer + document security store dokumentets sikkerhetslager + document structure dokumentstruktur + form fill skjemautfylling + its alternate text is inside another element's and will never be read den alternative teksten ligger inni et annet elements og vil aldri bli lest + links lenker + metadata metadata + no alternate text ingen alternativ tekst + no header cells (TH) ingen overskriftsceller (TH) + no summary ikke noe sammendrag + not in a TR ikke i en TR + not in a Table, THead, TBody or TFoot ikke i en Table, THead, TBody eller TFoot + not in an L (list) ikke i en L (liste) + not in an LI (list item) ikke i en LI (listeelement) + other changes andre endringer + page content sideinnhold + page {n} is out of range (1–{count}) side {n} er utenfor området (1–{count}) + pages added or removed sider lagt til eller fjernet + rows have between {min} and {max} columns radene har mellom {min} og {max} kolonner + the first heading is an H{n}; headings should not skip levels den første overskriften er en H{n}; overskrifter bør ikke hoppe over nivåer + the signed version could not be read den signerte versjonen kunne ikke leses + {n} pages and no bookmarks {n} sider og ingen bokmerker + {page} is not a page number or label {page} er ikke et sidetall eller en sideetikett + {ty} element on page {p}: {what} {ty}-element på side {p}: {what} + {ty} element: {what} {ty}-element: {what} + {who}, certificate issued by an unknown issuer. {who}, sertifikat utstedt av en ukjent utsteder. + {who}, certificate issued by {issuer}. {who}, sertifikat utstedt av {issuer}. + {} annotation on page {p} isn't tagged {}-merknad på side {p} er ikke tagget + {} annotations aren't allowed {}-merknader er ikke tillatt + … and {n} more … og {n} til + A button En knapp + A diagonal CONFIDENTIAL watermark on every page. Et diagonalt KONFIDENSIELT-vannmerke på hver side. + A document needs at least one page: copy instead Et dokument trenger minst én side: kopier i stedet + A form field has additional actions Et skjemafelt har tilleggshandlinger + A link En lenke + A script Et skript + A selected field has no widget on any page, so the fields can't be edited together. Et valgt felt har ingen kontroll på noen side, så feltene kan ikke redigeres sammen. + A stream refers to an external file En strøm viser til en ekstern fil + A stream uses LZW compression En strøm bruker LZW-komprimering + A summary describes the table's purpose and layout. Add a Summary attribute to each table. Et sammendrag beskriver tabellens formål og oppsett. Legg til et Summary-attributt på hver tabell. + About Om + Accepted Godtatt + Accessibility permission flag Tilgjengelighetstillatelsesflagg + Accessibility permission flag is set Tilgjengelighetstillatelsesflagget er satt + Add Page Numbers Legg til sidetall + Add arrow Legg til pil + Add background Legg til bakgrunn + Add bookmark Legg til bokmerke + Add callout Legg til bildeforklaring + Add checkmark Legg til hake + Add cloud Legg til sky + Add connected lines Legg til sammenkoblede linjer + Add cross Legg til kryss + Add dot Legg til prikk + Add drawing Legg til tegning + Add field Legg til felt + Add file attachment Legg til filvedlegg + Add footer Legg til bunntekst + Add header Legg til topptekst + Add header & footer Legg til topptekst og bunntekst + Add highlight Legg til utheving + Add image Legg til bilde + Add inserted text Legg til innsatt tekst + Add line Legg til linje + Add link Legg til lenke + Add oval Legg til oval + Add polygon Legg til polygon + Add rectangle Legg til rektangel + Add redaction mark Legg til sladdemerke + Add signature Legg til signatur + Add squiggly underline Legg til bølgete understreking + Add stamp Legg til stempel + Add sticky note Legg til notatlapp + Add strikethrough Legg til gjennomstreking + Add text box Legg til tekstboks + Add underline Legg til understreking + Add watermark Legg til vannmerke + Add {what} Legg til {what} + Added the first {n} files in {folder} La til de første {n} filene i {folder} + Adding images arrives on the web with file pickers for images Å legge til bilder kommer på nettet med filvelgere for bilder + Algorithm Algoritme + Align fields Juster felt + All annotations are tagged Alle merknader er tagget + All form fields are tagged Alle skjemafelt er tagget + All form fields have description Alle skjemafelt har beskrivelse + All multimedia objects are tagged Alle multimedieobjekter er tagget + All page content is tagged Alt sideinnhold er tagget + Allowed Tillatt + Alternate Text Alternativ tekst + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. Alternativ tekst inne i et element som allerede har alternativ tekst, blir aldri lest. Fjern den indre alternative teksten. + Alternate text must be associated with some content Alternativ tekst må være knyttet til noe innhold + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. Alternativ tekst på et element som inneholder en lenke eller et felt, skjuler merknaden for hjelpeteknologi. Flytt den alternative teksten. + Alternate text on an element without page content is never reached. Remove it or attach the element to content. Alternativ tekst på et element uten sideinnhold nås aldri. Fjern den, eller knytt elementet til innhold. + Alternate text should not hide annotation Alternativ tekst bør ikke skjule merknader + Alternate text that will never be read Alternativ tekst som aldri blir lest + An action is already running En handling kjører allerede + An embedded file has no AFRelationship En innebygd fil mangler AFRelationship + An image asks to be interpolated Et bilde ber om å bli interpolert + An image has alternates or OPI information Et bilde har alternativer eller OPI-informasjon + Animation that flashes can cause seizures. Check scripts and media for flicker. Animasjon som blinker, kan utløse anfall. Sjekk skript og medier for flimring. + Another file dialog is still open. Finish with it first. En annen fildialog er fortsatt åpen. Fullfør den først. + Application Program + Applied 1 redaction mark. Save to remove the content from the file. Brukte 1 sladdemerke. Lagre for å fjerne innholdet fra filen. + Applied {n} redaction marks. Save to remove the content from the file. Brukte {n} sladdemerker. Lagre for å fjerne innholdet fra filen. + Appropriate nesting Riktig nesting + Arbitrary Mask Vilkårlig maske + Associated with content Knyttet til innhold + At least one signature has problems. Minst én signatur har problemer. + At least one signature is invalid. Minst én signatur er ugyldig. + Attachment Vedlegg + Basic constraints Grunnleggende begrensninger + Beveled Skråkant + Black Svart + Black on white Svart på hvitt + Blue Blå + Bookmarks are present in large documents Bokmerker finnes i store dokumenter + Both Begge + Button Properties Knappegenskaper + Button Style: Knappestil: + Calculate Beregn + Callout Bildeforklaring + Cancelled Avbrutt + Center Footer Text Bunntekst midten + Center Header Text Topptekst midten + Center horizontally Midtstill vannrett + Center vertically Midtstill loddrett + Certificate Viewer Sertifikatvisning + Certified by {by} Sertifisert av {by} + Change comment properties Endre kommentaregenskaper + Change document properties Endre dokumentegenskaper + Change field properties Endre feltegenskaper + Change initial view Endre startvisning + Change link properties Endre lenkeegenskaper + Change tag properties Endre tag-egenskaper + Change {key} Endre {key} + Changing the document Endring av dokumentet + Character encoding Tegnkoding + Character limit: Tegngrense: + Check Hake + Check Box Properties Egenskaper for avkrysningsboks + Check Box Style: Stil for avkrysningsboks: + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Kontroller at taggene leses i den rekkefølgen en seende leser ville brukt. Bruk verktøyet Leserekkefølge eller Tagger-panelet for å rette det. + CheckBox Avkrysningsboks + Checked Avkrysset + Choose a folder for the split files Velg en mappe for de oppdelte filene + Choose a folder on this computer to pin Velg en mappe på denne datamaskinen å feste + Click on the page to add a {tool}, or drag to set its size Klikk på siden for å legge til {tool}, eller dra for å angi størrelsen + Click text or an image to edit it Klikk på tekst eller et bilde for å redigere det + Color Images Fargebilder + Color Spaces Fargerom + Color contrast Fargekontrast + Comb of characters Tegnkam + Combo Kombinasjonsboks + Coming in milestone {m}. Items marked Ready work today. Kommer i milepæl {m}. Elementer merket Klar fungerer i dag. + Command Kommando + Commenting Kommentering + Comments and markups Kommentarer og markeringer + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. Kommentarer, lenker og felt må være i tag-treet for å bli lest opp. Tagg merknadene. + Completed Fullført + Configure a Digital ID for Signing Sett opp en digital ID for signering + Connected lines Sammenkoblede linjer + Content Streams Innholdsstrømmer + Content copying Kopiering av innhold + Content copying for accessibility Kopiering av innhold for tilgjengelighet + Contributors Bidragsytere + Copied {what} Kopierte {what} + Copy or cut pages first Kopier eller klipp ut sider først + Could not save: {e} Kunne ikke lagre: {e} + Couldn't add {name}: {e} Kunne ikke legge til {name}: {e} + Couldn't combine files: {e} Kunne ikke slå sammen filene: {e} + Couldn't create a PDF: {e} Kunne ikke lage en PDF: {e} + Couldn't download {name}: {e} Kunne ikke laste ned {name}: {e} + Couldn't extract pages: {e} Kunne ikke trekke ut sider: {e} + Couldn't fit the visible content: {e} Kunne ikke tilpasse det synlige innholdet: {e} + Couldn't follow linked PDF: {reason} Kunne ikke følge lenket PDF: {reason} + Couldn't import the signature image: {e} Kunne ikke importere signaturbildet: {e} + Couldn't open revision {n}: {e} Kunne ikke åpne revisjon {n}: {e} + Couldn't open the result: {e} Kunne ikke åpne resultatet: {e} + Couldn't open the signed version: {e} Kunne ikke åpne den signerte versjonen: {e} + Couldn't open {name}: {e} Kunne ikke åpne {name}: {e} + Couldn't optimize the file Kunne ikke optimalisere filen + Couldn't optimize the file: {e} Kunne ikke optimalisere filen: {e} + Couldn't print from the browser ({e}); downloaded {name} to print instead Kunne ikke skrive ut fra nettleseren ({e}); lastet ned {name} for utskrift i stedet + Couldn't read {name}: {e} Kunne ikke lese {name}: {e} + Couldn't recover {name}: {e} Kunne ikke gjenopprette {name}: {e} + Couldn't reload {name}: {e} Kunne ikke laste {name} på nytt: {e} + Couldn't save the image: {e} Kunne ikke lagre bildet: {e} + Couldn't save {name}: {e} Kunne ikke lagre {name}: {e} + Couldn't save: {e} Kunne ikke lagre: {e} + Couldn't show the file picker. Please try again. Kunne ikke vise filvelgeren. Prøv igjen. + Couldn't split: {e} Kunne ikke dele opp: {e} + Couldn't summarize comments: {e} Kunne ikke oppsummere kommentarer: {e} + Couldn't take the snapshot: {e} Kunne ikke ta øyeblikksbildet: {e} + Couldn't unlock {name}: {e} Kunne ikke låse opp {name}: {e} + Couldn't use the photo: {e} Kunne ikke bruke bildet: {e} + Couldn't use {name}: {e} Kunne ikke bruke {name}: {e} + Couldn't write {name}: {e} Kunne ikke skrive {name}: {e} + the name is not a plain file name navnet er ikke et vanlig filnavn + Couldn't write {path}: {e} Kunne ikke skrive {path}: {e} + Create link Opprett lenke + Created 1 link Opprettet 1 lenke + Created a PDF from the first {n} files Opprettet en PDF fra de første {n} filene + Created a comment summary Opprettet en kommentaroppsummering + Created {n} links Opprettet {n} lenker + Credit Cards Kredittkort + Crop page Beskjær side + Custom font Egendefinert skrift + Custom stamps arrive on the web with file pickers for images Egendefinerte stempler kommer på nettet med filvelgere for bilder + Cut {what} Klipp ut {what} + Dark gray theme Mørkegrått tema + Dashed Stiplet + Dates Datoer + Delete bookmark Slett bokmerke + Delete content Slett innhold + Delete document JavaScript Slett dokument-JavaScript + Delete field Slett felt + Delete image Slett bilde + Delete link Slett lenke + Delete page Slett side + Delete selected fields Slett valgte felt + Deleted Slettet + Detected 1 form field Fant 1 skjemafelt + Detected {n} form fields Fant {n} skjemafelt + Device colour is used, but there is no PDF/A output intent Enhetsfarge brukes, men det finnes ingen PDF/A-utdataintensjon + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) DeviceCMYK brukes: det krever en CMYK-utdataintensjon (legges ikke til automatisk) + Diagnostic details Diagnostiske detaljer + Digital Signature Properties Egenskaper for digital signatur + Discard all comments, forms and multimedia Forkast alle kommentarer, skjemaer og multimedia + Discard all links, actions and JavaScript Forkast alle lenker, handlinger og JavaScript + Discard all object data (file attachments) Forkast alle objektdata (filvedlegg) + Discard bookmarks Forkast bokmerker + Discard document information and metadata Forkast dokumentinformasjon og metadata + Discard hidden layer content Forkast innhold i skjulte lag + Discard hidden text Forkast skjult tekst + Discard private data of other applications Forkast private data fra andre programmer + Distribute fields Fordel felt + Document Overhead Dokumentoverhead + Document assembly Dokumentsammensetting + Document has appropriate color contrast Dokumentet har tilstrekkelig fargekontrast + Document is not image-only PDF Dokumentet er ikke en PDF med bare bilder + Document is tagged PDF Dokumentet er tagget PDF + Document sanitized. Save to finish: saving rewrites the whole file. Dokumentet er renset. Lagre for å fullføre: lagring skriver hele filen på nytt. + Document structure provides a logical reading order Dokumentstrukturen gir en logisk leserekkefølge + Document title is showing in title bar Dokumenttittelen vises i tittellinjen + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. Dokumenter på mer enn 20 sider trenger bokmerker for navigering. Legg til bokmerker for hovedavsnittene. + Double-click Dobbeltklikk + Downloaded {name} Lastet ned {name} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) Nedlasting av vedlegg på nettet kommer med M3.10 ({n} byte klare) + Drag a rectangle around the area to copy Dra et rektangel rundt området som skal kopieres + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes Dra et rektangel på en side for å beskjære den; dobbeltklikk en side for Angi sidebokser + Drag a rectangle to create a link; double-click a link to edit it Dra et rektangel for å opprette en lenke; dobbeltklikk en lenke for å redigere den + Drag the corners onto the document's edges, then choose Enhance page. Dra hjørnene til dokumentets kanter, og velg deretter Forbedre side. + Drag to draw the area where the signature should appear. Dra for å tegne området der signaturen skal vises. + Dropdown Properties Egenskaper for nedtrekksliste + Duplicate field Dupliser felt + Duplicate page Dupliser side + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. Hvert felt trenger et verktøytips som sier hva som skal fylles inn. Legg til ett under Feltegenskaper > Generelt > Verktøytips. + Edit comment Rediger kommentar + Edit content Rediger innhold + Edit document JavaScript Rediger dokument-JavaScript + Edit fields Rediger felt + Edit script of {name} Rediger skript for {name} + Edit text Rediger tekst + Editing Redigering + Email Addresses E-postadresser + Embedded Files Innebygde filer + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) Innebygde filer må selv være PDF/A-filer (bruk PDF/A-3 for andre) + Encryption Kryptering + Endpoint Endepunkt + Erase Visk ut + Eraser Viskelær + Even pages Partallssider + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. Alt på en side må være tagget eller merket som artefakt (dekor, topptekster, bunntekster). Tagg det utaggede innholdet. + Execute a menu item Kjør et menyelement + Export certificate Eksporter sertifikat + Exported to {path} Eksportert til {path} + Extended Graphics States Utvidede grafikktilstander + Field Felt + Field descriptions Feltbeskrivelser + Figures alternate text Alternativ tekst for figurer + Figures require alternate text Figurer krever alternativ tekst + File attachments Filvedlegg + Files are combined in this order. Leave Pages empty to take every page. Filene slås sammen i denne rekkefølgen. La Sider stå tomt for å ta med alle sidene. + Fill in {name} Fyll ut {name} + Filling of form fields Utfylling av skjemafelt + Flatten Flat ut + Flip image Vend bilde + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. Skrifter må tilordne tegnene sine til Unicode slik at teksten kan leses og kopieres. Rett skriftene eller lag PDF-en på nytt. + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. Skjemafelt må være i tag-treet (Form-tagger) for å bli lest opp. Tagg skjemafeltene. + Formulas and similar non-text elements need alternate text. Add alternate text to them. Formler og lignende elementer som ikke er tekst, trenger alternativ tekst. Legg til alternativ tekst på dem. + Gray Grå + Grayscale Images Gråtonebilder + Green Grønn + Green on black Grønn på svart + H / Space (hold) H / Mellomrom (hold) + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. Overskriftsceller (TH) forteller lytteren hva hver kolonne eller rad inneholder. Merk overskriftscellene. + Headers Topptekster + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. Overskriftsnivåer bør ikke hoppe over nivåer (en H1 etterfulgt av en H3). Tagg overskriftene på nytt. + Headings Overskrifter + Hidden layers Skjulte lag + Hidden text Skjult tekst + Hide a field Skjul et felt + Hides annotation Skjuler merknad + High Høy + Home / End Home / End + Horizontally Vannrett + Image-only PDF PDF med bare bilder + Import {name} Importer {name} + Importing data arrives on the web with file pickers for data files Import av data kommer på nettet med filvelgere for datafiler + Incorrect password. Try again. Feil passord. Prøv igjen. + Insert pages from {name} Sett inn sider fra {name} + Insert pages from {name} — 1 field renamed Sett inn sider fra {name} — 1 felt fikk nytt navn + Insert pages from {name} — fields renamed Sett inn sider fra {name} — felt fikk nytt navn + Inserted Satt inn + Inset Innfelt + Intended usage Tiltenkt bruk + Intersection Snitt + Invert Inverter + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). Uregelmessige tabeller forvirrer cellenavigering. Gi hver rad like mange celler (medregnet spenn). + Issued by Utstedt av + Issued to Utstedt til + Issuer Utsteder + Key usage Nøkkelbruk + Keywords Nøkkelord + LI must be a child of L LI må være et barn av L + Landscape pages Liggende sider + Lbl and LBody Lbl og LBody + Lbl and LBody must be children of LI Lbl og LBody må være barn av LI + Left Footer Text Bunntekst venstre + Left Header Text Topptekst venstre + Light blue Lyseblå + Links, actions and JavaScripts Lenker, handlinger og JavaScript + List Liste + List Box Properties Egenskaper for listeboks + List items Listeelementer + List items belong in a list. Fix the list's tags. Listeelementer hører hjemme i en liste. Rett listens tagger. + List labels and bodies belong in a list item. Fix the list's tags. Listeetiketter og -innhold hører hjemme i et listeelement. Rett listens tagger. + Lists Lister + Load document font Last inn dokumentskrift + Local cross-PDF links require the desktop app Lokale lenker mellom PDF-er krever skrivebordsappen + Lock comment Lås kommentar + Logical Reading Order Logisk leserekkefølge + Low Lav + Make scans searchable, then make them smaller. Gjør skanninger søkbare, og gjør dem deretter mindre. + Mark all Merk alle + Mark as Confidential Merk som konfidensielt + Mark differences Marker forskjeller + Mark figure as decorative Merk figur som dekorativ + Mark for redaction Merk for sladding + Mark pages for redaction Merk sider for sladding + Match field sizes Tilpass feltstørrelser + Maximum Maksimum + Measuring tool Måleverktøy + Merged 1 file into {path} Slo sammen 1 fil til {path} + Merged {n} files into {path} Slo sammen {n} filer til {path} + Merging data files needs the desktop app Sammenslåing av datafiler krever skrivebordsappen + Metadata Metadata + Midpoint Midtpunkt + Minimum Minimum + Models Modeller + Mouse Down Museknapp ned + Mouse Enter Mus inn + Mouse Exit Mus ut + Mouse Up Museknapp opp + Move bookmark Flytt bokmerke + Move comment Flytt kommentar + Move image Flytt bilde + Move page Flytt side + Move pages Flytt sider + Named Destinations Navngitte mål + Navigation links Navigasjonslenker + Navigation links are not repetitive Navigasjonslenker er ikke repeterende + Nested alternate text Nestet alternativ tekst + New {tool} comments will look like this one Nye {tool}-kommentarer vil se ut som denne + No PDFs, images or text files found in {folder} Fant ingen PDF-er, bilder eller tekstfiler i {folder} + No form fields were detected Ingen skjemafelt ble oppdaget + No inaccessible scripts Ingen utilgjengelige skript + No links to remove Ingen lenker å fjerne + No pages match those choices Ingen sider samsvarer med de valgene + No unlinked web addresses were found in the text Fant ingen nettadresser uten lenke i teksten + None of those files can be made into a PDF; use PDFs, images or .txt files Ingen av de filene kan gjøres om til PDF; bruk PDF-er, bilder eller .txt-filer + Note Notat + Number every page in the footer. Nummerer hver side i bunnteksten. + Odd pages Oddetallssider + On Blur Ved fokustap + On Focus Ved fokus + On the web, open or drop an image to convert it På nettet: åpne eller slipp et bilde for å konvertere det + Open a web link Åpne en nettlenke + Opened with Åpnet med + Opening the browser's print dialog… Åpner nettleserens utskriftsdialog… + Optimization cancelled Optimalisering avbrutt + Optimization is already running Optimalisering kjører allerede + Optimize Scanned Documents Optimaliser skannede dokumenter + Options Alternativer + Orange Oransje + Other elements alternate text Alternativ tekst for andre elementer + Other elements that require alternate text Andre elementer som krever alternativ tekst + Outline Disposisjon + PDF producer PDF-produsent + PDF version PDF-versjon + Page Content Sideinnhold + Page Range Sideområde + Page does not require timed responses Siden krever ikke tidsbegrensede svar + Page thumbnails Miniatyrbilder av sider + Page will not cause screen flicker Siden vil ikke forårsake skjermflimring + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Sider som bare er bilder av tekst, kan ikke leses opp eller søkes i. Gjenkjenn tekst (OCR) for å legge til et tekstlag. + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. Sider med lenker, kommentarer eller felt bør ha tabulatorrekkefølge etter strukturen. Reparasjonen setter hver sides tabulatorrekkefølge til Bruk dokumentstruktur. + Passed Bestått + Password protection will be applied when you save Passordbeskyttelse tas i bruk når du lagrer + Path Bane + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft tegner uten grafikkprosessor, så det kan gå tregt. Det kan hjelpe å oppdatere grafikkdriveren. + People may need more time to respond. Check that nothing has a time limit. Noen kan trenge mer tid på å svare. Kontroller at ingenting har en tidsgrense. + Phone Number Telefonnummer + Phone Numbers Telefonnumre + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. Bilder trenger alternativ tekst som beskriver dem, eller må merkes som dekorative. Legg til alternativ tekst for hver figur. + Pink Rosa + Pinned folders need the desktop app Festede mapper krever skrivebordsappen + Pinned {folder} to Home Festet {folder} til Hjem + Place saved {what} Plasser lagret {what} + Planned for milestone {m} — open to see what it will include Planlagt for milepæl {m} – åpne for å se hva den vil inneholde + Planned · {m} Planlagt · {m} + Polygon Polygon + Polyline Polylinje + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Portabel modus er av: {folder} kan ikke skrives til ({error}). Innstillingene lagres i brukermappen din i stedet. + Portrait pages Stående sider + Position Posisjon + PostScript XObjects aren't allowed PostScript-XObjects er ikke tillatt + Prepare for Distribution Klargjør for distribusjon + Preparing sheet {done} of {total} for printing… Klargjør ark {done} av {total} for utskrift… + Preparing {n} sheets for printing… Klargjør {n} ark for utskrift… + Primary language Primærspråk + Printing Skriver ut + Private data of other applications Private data fra andre programmer + Public key Offentlig nøkkel + Purple Lilla + PushButton Trykknapp + Radio Alternativknapp + Radio Button Properties Egenskaper for alternativknapp + Re-type Password Gjenta passord + Red Rød + Redid {label} Gjorde om {label} + Regularity Regelmessighet + Rejected Avvist + Reliable character encoding is provided Pålitelig tegnkoding er angitt + Remove hidden information and flatten comments and form fields, so recipients see only the pages. Fjern skjult informasjon og flat ut kommentarer og skjemafelt, slik at mottakerne bare ser sidene. + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Fjern skjult informasjon, metadata, skript og innebygd innhold, og skriv filene på nytt. + Remove redaction marks Fjern sladdemerker + Remove saved {what} Fjern lagret {what} + Removed 1 hidden item. Save to remove them from the file. Fjernet 1 skjult element. Lagre for å fjerne det fra filen. + Removed 1 link Fjernet 1 lenke + Removed {n} hidden items. Save to remove them from the file. Fjernet {n} skjulte elementer. Lagre for å fjerne dem fra filen. + Removed {n} links Fjernet {n} lenker + Rename bookmark Gi bokmerke nytt navn + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Gjengivelse: hayro (oppstart) · Grensesnitt: egui · Ikoner: Lucide (ISC) · Skrifter: Inter, JetBrains Mono, Dancing Script (OFL) + Repairs Reparasjoner + Repeated navigation links should be skippable. Check links that appear on every page. Gjentatte navigasjonslenker bør kunne hoppes over. Kontroller lenker som vises på hver side. + Replace page Erstatt side + Replace pages Erstatt sider + Replaced Erstattet + Replacing images arrives on the web with image file pickers Erstatting av bilder kommer på nettet sammen med bildefilvelgere + Replacing images on page {p} arrives on the web with image pickers ({i}) Erstatting av bilder på side {p} kommer på nettet sammen med bildevelgere ({i}) + Report Options Rapportalternativer + Reset a form Tilbakestill et skjema + Resize comment Endre størrelse på kommentar + Restrictions Begrensninger + Restrictions lifted Begrensninger opphevet + Reverted to the last saved version Gikk tilbake til sist lagrede versjon + Right Footer Text Høyre bunntekst + Right Header Text Høyre topptekst + Rotate image Roter bilde + Rotate page Roter side + Rotate page {label} clockwise Roter side {label} med klokken + Rotate page {label} counterclockwise Roter side {label} mot klokken + Rows Rader + Run Kjør + Run JavaScript Kjør JavaScript + Run a JavaScript Kjør et JavaScript + Run form script Kjør skjemaskript + SHA-1 digest SHA-1-sammendrag + SHA-256 digest SHA-256-sammendrag + Sanitize Rens + Sanitize Documents Rens dokumenter + Save as PDF/A-1b Lagre som PDF/A-1b + Save as PDF/A-2b Lagre som PDF/A-2b + Save as PDF/A-3b Lagre som PDF/A-3b + Save image Lagre bilde + Save or revert changes before selecting another tag. Lagre eller forkast endringene før du velger en annen tagg. + Saved the print-ready PDF to {path} Lagret den utskriftsklare PDF-en til {path} + Saved {name} Lagret {name} + Saved {place}: {before} → {after} (not smaller){detail} Lagret {place}: {before} → {after} (ikke mindre){detail} + Saved {place}: {before} → {after} ({pct}% smaller){detail} Lagret {place}: {before} → {after} ({pct} % mindre){detail} + Saved, but reopening failed: {e} Lagret, men gjenåpning mislyktes: {e} + Screen flicker Skjermflimring + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. Skjermlesere velger stemme ut fra dokumentets språk. Angi det under Dokumentegenskaper > Avansert > Språk. + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. Skriptet innhold må kunne brukes med tastatur og hjelpeteknologi. Kontroller dokumentets skript. + Scripts Skript + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. Skript kjører i en sandkasse uten fil- eller nettverkstilgang. Med JavaScript av fungerer Acrobats standardfunksjoner for formatering, validering og beregning fortsatt. + Security method Sikkerhetsmetode + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Sikkerhetsinnstillingene må tillate at hjelpeteknologi leser innholdet («Tillat teksttilgang for skjermlesere»). Endre dokumentets sikkerhet for å tillate det. + Security will be removed when you save Sikkerheten fjernes når du lagrer + Self-signed Selvsignert + Serial number Serienummer + Set alternate text Angi alternativ tekst + Set bookmark destination Angi bokmerkemål + Set document title Angi dokumenttittel + Set status Accepted Sett status Godtatt + Set status Cancelled Sett status Avbrutt + Set status Completed Sett status Fullført + Set status None Sett status Ingen + Set status Rejected Sett status Avvist + Set status Unmarked Sett status Umerket + Set tab order Angi tabulatorrekkefølge + Set the image of {name} Angi bildet for {name} + Shadings Skyggelegginger + Show a field Vis et felt + Sign with a Digital ID Signer med en digital ID + Signed and all signatures are valid. Signert, og alle signaturer er gyldige. + Signed and saved to {path} Signert og lagret til {path} + Signer Underskriver + Signing time Signeringstidspunkt + Social Security Number Personnummer + Social Security Numbers Personnumre + Solid Heltrukket + Squiggly Bølgete + Star Stjerne + Strong Sterk + Structure Info Strukturinformasjon + Submit a form Send inn et skjema + TH and TD TH og TD + TH and TD must be children of TR TH og TD må være barn av TR + TR must be a child of Table, THead, TBody, or TFoot TR må være barn av Table, THead, TBody eller TFoot + Tab order Tabulatorrekkefølge + Tab order is consistent with structure order Tabulatorrekkefølgen samsvarer med strukturrekkefølgen + Table cells belong in a row. Fix the table's tags. Tabellceller hører hjemme i en rad. Rett opp tabellens tagger. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Tabellrader hører hjemme direkte i en tabell eller dens hode, kropp eller fot. Rett opp tabellens tagger. + Tables Tabeller + Tables must contain the same number of columns in each row and rows in each column Tabeller må ha like mange kolonner i hver rad og like mange rader i hver kolonne + Tables must have a summary Tabeller må ha et sammendrag + Tables should have headers Tabeller bør ha overskrifter + Tagged PDF Tagget PDF + Tagged annotations Taggede merknader + Tagged content Tagget innhold + Tagged form fields Taggede skjemafelt + Tagged multimedia Tagget multimedia + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). Tagger gir dokumentet en logisk struktur som hjelpeteknologi følger. Tagg dokumentet (Tagg PDF automatisk). + Text Field Properties Egenskaper for tekstfelt + Text language is specified Tekstspråk er angitt + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. Tekst må ha nok kontrast mot bakgrunnen for folk med nedsatt syn. Kontroller fargene med øyet eller med et kontrastverktøy. + Text recognition is already running Tekstgjenkjenning kjører allerede + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). Tekstgjenkjenning er ikke installert: modellene mangler (kjør `cargo xtask models`, eller sett PDFCRAFT_MODELS). + That didn't work: an internal error stopped it ({m}). Det gikk ikke: en intern feil stoppet det ({m}). + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. Det gikk ikke: en intern feil stoppet det ({m}). Dokumentene dine er uendret. + The Keychain's digital IDs couldn't be listed: {e} Kunne ikke liste opp de digitale ID-ene i nøkkelringen: {e} + The Windows store's digital IDs couldn't be listed: {e} Kunne ikke liste opp de digitale ID-ene i Windows-lageret: {e} + The clipboard has no image or text Utklippstavlen har verken bilde eller tekst + The document changed while you were choosing a file, so nothing was changed. Dokumentet ble endret mens du valgte en fil, så ingenting ble endret. + The document changed while you were editing field properties. Dokumentet ble endret mens du redigerte feltegenskaper. + The document has additional actions (/AA) Dokumentet har tilleggshandlinger (/AA) + The document has document-level JavaScript Dokumentet har JavaScript på dokumentnivå + The document has no XMP metadata Dokumentet har ingen XMP-metadata + The document is encrypted Dokumentet er kryptert + The document is no longer open. Dokumentet er ikke lenger åpent. + The document was closed before it could be saved. Dokumentet ble lukket før det kunne lagres. + The document's security settings don't allow filling in form fields Dokumentets sikkerhetsinnstillinger tillater ikke utfylling av skjemafelt + The field properties could not be changed. Feltegenskapene kunne ikke endres. + The field properties dialog is no longer open. Dialogen for feltegenskaper er ikke lenger åpen. + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. Skjemaet ber om å bli sendt til {u}; PdfCraft sender ikke skjemadata. Lagre dokumentet for å beholde det du har fylt inn. + The form asks viewers to make appearances (NeedAppearances) Skjemaet ber visningsprogrammet lage utseender (NeedAppearances) + The linked PDF opened, but its named destination was not applied. Den lenkede PDF-en ble åpnet, men det navngitte målet ble ikke brukt. + The linked PDF opened, but its page destination was out of range. Den lenkede PDF-en ble åpnet, men sidemålet var utenfor området. + The operation failed. Operasjonen mislyktes. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. Sidene ble trukket ut, men ikke slettet, fordi dokumentet ble endret mens du valgte en mappe. + The passwords don't match. Passordene er ikke like. + The selected area has been copied ({w} × {h} pixels) Det merkede området er kopiert ({w} × {h} piksler) + The selected fields could not be found. Select them again. Fant ikke de valgte feltene. Velg dem på nytt. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardskriftene kan ikke tegne «{c}» ({code}). Foreløpig kan bare vesteuropeiske tegn legges til som tekst. + The title in the metadata and the document information differ Tittelen i metadataene og dokumentinformasjonen er forskjellige + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. Vinduet bør vise en meningsfull tittel i stedet for filnavnet. Angi en tittel og Startvisning > Vis: Dokumenttittel. + There are no redaction marks to apply Det er ingen sladdemerker å bruke + This PDF uses the unsupported {s} action. Denne PDF-en bruker handlingen {s}, som ikke støttes. + This compatibility level supports only plain ASCII passwords. Dette kompatibilitetsnivået støtter bare rene ASCII-passord. + This document can't be checked: it couldn't be read for editing Dette dokumentet kan ikke kontrolleres: det kunne ikke leses for redigering + This document has no attachments. Dette dokumentet har ingen vedlegg. + This document has no bookmarks. Dette dokumentet har ingen bokmerker. + This document has no form fields. Dette dokumentet har ingen skjemafelt. + This document has no layers. Dette dokumentet har ingen lag. + This file is already as small as it can be made ({size}). No copy was saved. Denne filen er allerede så liten som den kan bli ({size}). Ingen kopi ble lagret. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. Dette skjemaet inneholder også XFA-data, som PdfCraft ikke leser ennå. Du kan fylle ut feltene, men Acrobat kan vise XFA-verdiene i stedet. + Thumbnails Miniatyrbilder + Timed responses Tidsbegrensede svar + Type Password Skriv inn passord + Type a password. Skriv inn et passord. + Typewriter Skrivemaskin + Unchecked Ikke avkrysset + Undid {label} Angret {label} + Unlock comment Lås opp kommentar + Unlocked {n} of {m} files Låste opp {n} av {m} filer + Update background Oppdater bakgrunn + Update header & footer Oppdater topptekst og bunntekst + Update watermark Oppdater vannmerke + Use {what} Bruk {what} + Valid Gyldig + Valid from Gyldig fra + Valid to Gyldig til + Validate Valider + Validity ends Gyldighet slutter + Validity starts Gyldighet starter + Vertically Loddrett + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. Video, lyd og rike medier må være tagget for å bli lest opp. Tagg multimediemerknadene. + Viewing Visning + Weak Svak + White on black Hvitt på svart + Wrote 1 file to {dir} Skrev 1 fil til {dir} + Wrote {n} files to {dir} Skrev {n} filer til {dir} + X Object Forms Form-XObjects + Yellow Gul + Yellow on black Gult på svart + You can pin up to {n} folders. Unpin one first. Du kan feste opptil {n} mapper. Løsne én først. + Your name Navnet ditt + Zip Code Postnummer + Zip Code + 4 Postnummer + 4 + `{command}` {when} `{command}` {when} + a bookmark cannot be moved inside itself et bokmerke kan ikke flyttes inn i seg selv + a document must keep at least one page et dokument må beholde minst én side + average gjennomsnitt + maximum maksimum + minimum minimum + no such document dokumentet finnes ikke + nothing in the file matches this document ingenting i filen passer til dette dokumentet + nothing to redo ingenting å gjøre om + nothing to undo ingenting å angre + pages sider + product (x) produkt (x) + sum (+) sum (+) + the document has no form fields dokumentet har ingen skjemafelt + the document has no page tree dokumentet har ikke noe sidetre + the document has no tags dokumentet har ingen tagger + the document is protected by a password dokumentet er beskyttet med passord + the image is empty bildet er tomt + the password is incorrect passordet er feil + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) modellene for tekstgjenkjenning er ikke installert (installer PdfCraft på nytt, eller sett PDFCRAFT_MODELS til mappen som inneholder dem) + the title must not be empty tittelen kan ikke være tom + there are no pages to print det er ingen sider å skrive ut + there are no redaction marks to apply det er ingen sladdemerker å bruke + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) dette dokumentet er signert: å skrive det om ville gjøre signaturene ugyldige (lagre det inkrementelt i stedet) + this file isn't XFDF, FDF, XML or text form data denne filen er ikke XFDF, FDF, XML eller skjemadata som tekst + {0}: {1} {0}: {1} + {field}: {e} {field}: {e} + {file} is too large for a stamp (at most {n} MB) {file} er for stor for et stempel (høyst {n} MB) + {folder} is already pinned to Home {folder} er allerede festet til Hjem + {label} failed: {e} {label} mislyktes: {e} + {name} has no action {name} har ingen handling + {name} is too large for a word list (at most 1 MB). {name} er for stor for en ordliste (høyst 1 MB). + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} sender skjemaet til {url}; PdfCraft sender ikke skjemadata. Lagre dokumentet for å beholde det du har fylt inn. + {name}: choosing images arrives on the web with file pickers for images {name}: bildevalg kommer på nett med filvelgere for bilder + {name}: the {other} action isn't supported yet {name}: handlingen {other} støttes ikke ennå + {who} in this document tried to open an address PdfCraft won't open: {e}. {who} i dette dokumentet prøvde å åpne en adresse PdfCraft ikke åpner: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} i dette dokumentet prøvde å åpne en adresse PdfCraft ikke åpner: {e}. Bare nettlenker (http, https) og e-postlenker (mailto) åpnes fra dokumenter. + View more Vis mer + View less Vis mindre + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Fortsett bare hvis du stoler på dette dokumentet. En adresse kan inneholde informasjon fra dokumentet, for eksempel det du skrev inn i skjemaet. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. Skriv ut ensidig. Hold arkene i rekkefølge, klipp langs merkene, og stable bunkene fra venstre til høyre, ovenfra og ned. + 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. Signer på et hvitt ark og ta et bilde av det i sterkt, jevnt lys (bruk blitsen). Velg bildet eller slipp det her: PdfCraft finner signaturen og fjerner papiret. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Skriften i dette feltet kan ikke tegne “{c}” ({code}), så PdfCraft viser ? i stedet. Verdien lagres, og andre PDF-lesere tegner feltet på nytt ut fra den. + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} kan ikke skrives inn i PDF-en ennå: Fyll ut og signer-tekst støtter bare vesteuropeiske tegn. Velg et numerisk format som dd.mm.yyyy. + January januar + February februar + March mars + April april + May mai + June juni + July juli + August august + September september + October oktober + November november + December desember + Certificate authority Sertifikatutsteder + End entity Sluttenhet + Float panel Frikoble panel + Move to group Flytt til gruppe + Panels Paneler + Resize panel window Endre størrelse på panelvindu + Resize panels Endre størrelse på paneler + Grab bag Diverse + No contributor data was built into this copy. Ingen bidragsyterdata ble bygd inn i denne kopien. + No model credits were built into this copy. Ingen modellkrediteringer ble bygd inn i denne kopien. + Reverse the order Snu rekkefølgen + Show Vis + Sort Sorter + Table Tabell + The field {name} no longer exists. Feltet {name} finnes ikke lenger. + {n} contributors · {c} commits {n} bidragsytere · {c} commits + Username Brukernavn + Display name Visningsnavn + Real name Egentlig navn + Name (A–Z) Navn (A–Å) + Merged PRs Sammenslåtte PR-er + PRs PR-er + Commits Commits + Lines added Linjer lagt til + +LOC +LOC + Lines deleted Linjer slettet + −LOC −LOC + Line delta Linjedifferanse + ΔLOC ΔLOC + Binary assets added Binærfiler lagt til + +Bin +Bin + Binary assets removed Binærfiler fjernet + −Bin −Bin + First commit Første commit + First Første + Last commit Siste commit + Last Siste + Company Selskap + Model Modell + Version Versjon + % of all commits % av alle commits + Lines +/− Linjer +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR-er, {commits} commits, +{added} / −{deleted} linjer (Δ {delta}), +{bin_added} / −{bin_deleted} binærfiler, {first} – {last} + of {name} av {name} + of {pages} av {pages} + on page {label} på side {label} + open to see what it will include åpne for å se hva som blir med + ships in milestone {m} kommer i milepæl {m} + signature signatur + sorted ascending sortert stigende + sorted descending sortert synkende + to til + to {n} til {n} + watermark vannmerke + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy eller yy år · m eller mm måned · mmm eller mmmm månedsnavn · d eller dd dag · ddd eller dddd ukedag. H, h, M, s og t er tidsbokstaver og kan ikke brukes alene: sett {escape} foran en bokstav for å vise den som den er. + {c} of {n}{more} {c} av {n}{more} + {dpi} pixels/inch {dpi} piksler/tomme + {kind} Properties Egenskaper for {kind} + {kind} field {kind}-felt + {kind} · page {p} {kind} · side {p} + {kind} — click to show it on the page {kind} — klikk for å vise det på siden + {k} of {n} {k} av {n} + {label} (1 issue) {label} (1 problem) + {label} ({n} issues) {label} ({n} problemer) + {month} {day}, {y} {day}. {month} {y} + {month} {y} {month} {y} + {msg} to {dir} {msg} til {dir} + {name} (default) {name} (standard) + {name} can't be combined: {why} {name} kan ikke slås sammen: {why} + {name} is read-only {name} er skrivebeskyttet + {name} — unsaved changes {name} — ulagrede endringer + {n} days ago {n} dager siden + {n} differences found {n} forskjeller funnet + {n} fields selected {n} felt valgt + {n} files {n} filer + {n} files done {n} filer ferdige + {n} files need attention {n} filer trenger oppmerksomhet + {n} h ago {n} t siden + {n} instances {n} forekomster + {n} issues {n} problemer + {n} marks {n} merker + {n} match(es) marked for redaction. {n} treff merket for sladding. + {n} measurements use an unsupported format and are left out. {n} målinger bruker et format som ikke støttes, og er utelatt. + {n} min ago {n} min siden + {n} pages {n} sider + {n} pages selected. {n} sider valgt. + {n} selected {n} valgt + {n} tags {n} tagger + {n} word(s) or phrase(s) {n} ord eller fraser + {n} {kind} {n} {kind} + {rule} - {status} {rule} - {status} + {s} action {s}-handling + {verb} Background {verb} bakgrunn + {verb} Header and Footer {verb} topptekst og bunntekst + {verb} Watermark {verb} vannmerke + {verb} as "{name}" {verb} som "{name}" + {who} in this document wants to open this address in your browser: {who} i dette dokumentet vil åpne denne adressen i nettleseren din: + {who} in this document wants to open your email app with this address: {who} i dette dokumentet vil åpne e-postprogrammet ditt med denne adressen: + {words} on {pages} {words} på {pages} + — (protection is applied when you save) — (beskyttelsen tas i bruk når du lagrer) + “{name}” has security restrictions. Enter its permissions password to lift them. “{name}” har sikkerhetsbegrensninger. Skriv inn tillatelsespassordet for å oppheve dem. + “{name}” is protected. Enter a password to open it. “{name}” er beskyttet. Skriv inn et passord for å åpne den. + Minimize Minimer + Maximize Maksimer + Restore Gjenopprett + Use system title bar Bruk systemets tittellinje + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. Vis en enkel tittellinje for vindusknappene i stedet for fanelinjen. GNOME tegner ikke tittellinjer for apper, så denne følger ikke temaet. + Move tag earlier Flytt tagg tidligere + Move tag later Flytt tagg senere + Next tab Neste fane + Previous tab Forrige fane + Hide hover previews for highlights without comment text Skjul forhåndsvisning ved peking for uthevinger uten kommentartekst + Digitally signed by Digitalt signert av + DN: DN: + Reason: Årsak: + Location: Sted: + Date: Dato: + The labels are in English: the signature's font can't draw them in this language. Etikettene er på engelsk: signaturens skrift kan ikke tegne dem på dette språket. + Font size Skriftstørrelse + No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. Fant ingen skrivere. Kontroller at skriveren er installert og at utskriftskøen kjører, og åpne Skriv ut på nytt. + Make Properties Default Bruk egenskapene som standard + Scroll speed (mouse wheel and arrow keys) Rullehastighet (musehjul og piltaster) + Save over the changed file? Lagre over den endrede filen? + “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. «{name}» ble endret på disken etter at den ble åpnet eller sist lagret. Lagrer du nå, erstattes den nyere versjonen med denne. + Save anyway Lagre likevel + Sending sheet {s} of {n} to {printer}… Sender ark {s} av {n} til {printer}… + What takes up the space in this file, by kind of content. Hva som tar plass i denne filen, etter innholdstype. + This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Denne filen er endret på disken. Last inn på nytt viser den nye versjonen og forkaster ulagrede endringer; Lagre erstatter den med denne versjonen. + Set page transitions Angi sideoverganger + The password must have at least 8 characters. Passordet må ha minst 8 tegn. + Open in Full Screen mode? Åpne i fullskjermmodus? + This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. Dette dokumentet ber om å bli åpnet i fullskjermmodus: siden fyller hele skjermen, og ingenting annet vises. Trykk Esc når som helst for å avslutte. + Full Screen Fullskjerm + Not now Ikke nå + Find untagged content Finn innhold uten tagger + No untagged content was found. Fant ikke noe innhold uten tagger. + Found {n} untagged content problems. Fant {n} problemer med innhold uten tagger. + Tag form fields Tagg skjemafelt + Mark as artifact Merk som artefakt + Remove this leaf tag from reading order and mark its direct content as an artifact Fjern denne taggen fra leserekkefølgen og merk innholdet dens som artefakt + Mark content as artifact Merk innhold som artefakt + Word count Ordtelling + Current page only Bare gjeldende side + Exclude tagged tables Utelat taggede tabeller + Exclude tagged code blocks Utelat taggede kodeblokker + Exclude image/OCR text Utelat bilde-/OCR-tekst + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. Utelatelser bruker PDF-tagger. Tabeller og kode uten tagger telles fortsatt. Utelating av bilder fjerner også usynlig tekst. + Words Ord + Characters Tegn + Characters without spaces Tegn uten mellomrom + Pages counted Sider talt + Some pages contain no countable text. Scanned images need OCR first. Noen sider inneholder ingen tekst som kan telles. Skannede bilder trenger tekstgjenkjenning først. + Some content could not be classified safely and was kept in the count. Noe innhold kunne ikke klassifiseres sikkert og ble beholdt i tellingen. + Counting words… {n} pages read Teller ord … {n} sider lest + Word counting stopped unexpectedly. Ordtellingen stoppet uventet. + Word count… Ordtelling… + Standard Standard + Mobile Mobil + Merge image fragments Slå sammen bildefragmenter + Optimizing… merging image fragments Optimaliserer … slår sammen bildefragmenter + Side by side Side om side + New file only Bare ny fil + Old file: {name} Gammel fil: {name} + New file: {name} Ny fil: {name} + Print as image Skriv ut som bilde + Resolution: Oppløsning: + Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Bruk dette når en skriver hopper over tekst, skyggelegging eller gjennomsiktighet. Utskriften er ikke lenger søkbar tekst; med Skriv ut i gråtoner blir bildene grå. + Crop Image Beskjær bilde + Remove Crop Fjern beskjæring + Trim the picture with its handles; Enter or Done applies Beskjær bildet med håndtakene; Enter eller Ferdig bekrefter + Redaction mark appearance Utseende på sladdemarkeringer + Outline colour: Kantfarge: + Fill colour: Fyllfarge: + Shown while the pointer is over a mark. Vises mens pekeren er over en markering. + Opacity: Gjennomsiktighet: + Make properties default Bruk egenskapene som standard + Pages selected Valgte sider + Page properties Sideegenskaper + Cut Image Klipp ut bilde + Redact Image Sladd bilde + Create Link… Opprett lenke… + Recognize Text… Gjenkjenn tekst… + Turn upside down (180°) Snu opp ned (180°) + 4 x 6 in label (101.6 x 152.4 mm) 4 x 6 tommer etikett (101,6 x 152,4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Beskjærer hver side til det trykte området, snur den rett vei og tilpasser den til arket uten å strekke. Én etikett per ark. + Collapse all pages Skjul alle sider + Expand all pages Utvid alle sider + +# Words that need a different translation in one place (see tr_ctx). +theme Appearance Utseende +action wizard Start Start +comment menu Edit Rediger +animation effects Off Av +animation effects On På +calendar date January januar +calendar date February februar +calendar date March mars +calendar date April april +calendar date May mai +calendar date June juni +calendar date July juli +calendar date August august +calendar date September september +calendar date October oktober +calendar date November november +calendar date December desember +calendar weekday Su sø +calendar weekday Mo ma +calendar weekday Tu ti +calendar weekday We on +calendar weekday Th to +calendar weekday Fr fr +calendar weekday Sa lø +signature pad Type Skriv +scroll speed Slow Sakte +scroll speed Normal Normal +scroll speed Fast Rask +certificate Subject Emne +certificate Issued to Utstedt til +certificate Issued by Utstedt av +certificate Valid from Gyldig fra +certificate Valid to Gyldig til +certificate Intended usage Tiltenkt bruk +certificate Version Versjon +certificate Serial number Serienummer +certificate Issuer Utsteder +certificate Validity starts Gyldighet starter +certificate Validity ends Gyldighet slutter +certificate Public key Offentlig nøkkel +certificate Basic constraints Grunnleggende begrensninger +certificate Key usage Nøkkelbruk +certificate Self-signed Selvsignert +certificate SHA-1 digest SHA-1-sammendrag +certificate SHA-256 digest SHA-256-sammendrag + +# Command ids (win over the English label for that menu item). +@id file.word_count Ordtelling… + +# Plural forms: one (1) | other (0, 2, 3 ...). +@plural Delete {n} field|Delete {n} fields Slett {n} felt|Slett {n} felt +@plural {n} byte|{n} bytes {n} byte|{n} byte +@plural {n} image optimized|{n} images optimized {n} bilde optimalisert|{n} bilder optimalisert +@plural {n} item discarded|{n} items discarded {n} element forkastet|{n} elementer forkastet +@plural {n} pixel|{n} pixels {n} piksel|{n} piksler +@plural {n} page|{n} pages {n} side|{n} sider +@plural {n} file|{n} files {n} fil|{n} filer +@plural {n} field|{n} fields {n} felt|{n} felt +@plural {n} comment|{n} comments {n} kommentar|{n} kommentarer +@plural {n} result|{n} results {n} resultat|{n} resultater +@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} feltnavn kolliderer med tidligere filer og blir omdøpt: {list}|{n} feltnavn kolliderer med tidligere filer og blir omdøpt: {list} +@plural {n} object selected|{n} objects selected {n} objekt valgt|{n} objekter valgt +@plural Downloaded {n} PDF|Downloaded {n} PDFs Lastet ned {n} PDF|Lastet ned {n} PDF-er + +# Keyboard key names, distinct from command labels. +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space Mellomrom diff --git a/crates/ui-egui/tests/fonts.rs b/crates/ui-egui/tests/fonts.rs index 9cfa36c8ab..feb2a12e57 100644 --- a/crates/ui-egui/tests/fonts.rs +++ b/crates/ui-egui/tests/fonts.rs @@ -318,8 +318,9 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { ("sl", include_str!("../src/i18n/sl.tsv")), ("lt", include_str!("../src/i18n/lt.tsv")), ("vi", include_str!("../src/i18n/vi.tsv")), + ("nb", include_str!("../src/i18n/nb.tsv")), ] { - let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ"); + let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűßæøåÆØÅ„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ"); for line in catalog.lines().filter(|l| !l.starts_with('#')) { if let Some(translation) = line.split('\t').nth(2) { text.extend(translation.chars().filter(|c| !c.is_whitespace())); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 995293af3d..bff29d927c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,7 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Vietnamese 100% of current key union; native-speaker community review; twelve-language mean ≈68%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Italian count after completing its catalog) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (superseded status line and language summary removed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -70,13 +69,11 @@ none has translated help. Vietnamese's **To `full`** hours are therefore residua | Greek | `el` | 2,341 entries | partial | no | | Turkish | `tr` | 2,313 entries | partial | yes | | Lithuanian | `lt` | 2,585 entries | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | -**14 other languages** ship (22 catalogs plus English = **23** interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12–13; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. -Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. -**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12 (Italian on 2026-10-14); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. -Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. +**15 other languages** ship (23 catalogs plus English = **24** interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12–13 (Italian on 2026-10-14, Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history @@ -84,5 +81,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, He |---|---|---| | 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` | | 2026-10-14 | minor | Italian: 2,528 entries after adding the 201 keys the other catalogs had and the code looks up | +| 2026-10-15 | minor | Norwegian Bokmål added as an interface and date language with a 2,674-entry catalog | +| 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | | 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 657e2eb247..236eb33973 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių** or **Tiếng Việt**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, and Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, and Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,11 +12,9 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`, `vi` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt`, `vi` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese and others) cover the current localizable surface (about 2,000–2,600 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each), and Italian has 2,528 entries; Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each), and Italian has 2,528 entries; Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). @@ -26,11 +24,11 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. + Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian and Norwegian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), the labels added since, and a review of the existing wording. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). diff --git a/docs/roadmap.md b/docs/roadmap.md index c7c2f44797..99c7376957 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -153,7 +153,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 724cbb5604..c825a6cdc1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9941,8 +9941,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading"