diff --git a/ROADMAP.md b/ROADMAP.md index 0afbdb7cfd..5bfe1fc817 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged) + +> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged) + > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) > **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) @@ -219,6 +223,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged. + +- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged. + +- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop. + - **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer. - **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged. @@ -747,6 +757,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. | +| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. | | 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. | | 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index b56e0de20d..1a54dbc077 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option Object::Name(n) => String::from_utf8_lossy(n).into_owned(), _ => return None, }; - let s = s.trim_matches('\0').trim().to_string(); - (!s.is_empty()).then_some(s) + let s = s.trim_matches('\0'); + // Typewriter outer whitespace belongs to the text (keep in step with render's inspector). + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter"); + let s = if keep_breaks { s } else { s.trim() }; + (!s.trim().is_empty()).then(|| s.to_owned()) } /// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c7a0fd9831..9af83b0542 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -137,11 +137,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) { return true; } - let subtype = d.name(b"Subtype").unwrap_or_default(); - if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") { + let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected)); + if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") { return true; } - if subtype == b"Stamp" { + if name_is(b"Subtype", b"Stamp") { if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) { return true; } @@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool { return true; } } - subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature") + name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature") } pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 { diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index cf800fa98a..32d28fc6ec 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -2250,3 +2250,27 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() { assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}"); } } + +#[test] +fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() { + let mut doc = fixture(); + let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter"))); + let page = page_refs(&doc).unwrap()[0]; + let mut entries = annots(&doc, page); + let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") }; + annotation.set(b"Subtype".to_vec(), Object::name("FreeText")); + annotation.set(b"IT".to_vec(), intent); + let contents = " \nAda\n\nLovelace\n\n "; + annotation.set(b"Contents".to_vec(), PdfString::text(contents)); + set_annots(&mut doc, page, entries).unwrap(); + let check = |doc: &Document| { + let list = summaries(doc); + let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed"); + assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace"); + assert_eq!(summary.subtype, "FreeText"); + assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter")); + assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark"); + }; + check(&doc); + check(&reopen(&doc)); +} diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs index 041ba9ddd8..bb5adf7ff8 100644 --- a/crates/audit/src/cover.rs +++ b/crates/audit/src/cover.rs @@ -15,7 +15,7 @@ //! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position. //! Positions compare lexicographically in paint order. -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, ops}; use pdfcraft_cos::{DecodeBudget, Dict, Document, Object}; use crate::AuditError; @@ -123,12 +123,13 @@ impl<'a> Walker<'a> { Ok(()) } - /// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to + /// Walk one stream's operators, read one at a time (a stream of millions of short operators + /// is never held whole), with the given graphics state. `at_do` is the `Do` position to /// stamp on rectangles found inside a form XObject, or `None` at page level (where each /// fill operator's own position is used). fn walk_ops( &mut self, - ops: &[Op], + ops: impl Iterator, gs: &mut Gs, stack: &mut Vec, resources: &Dict, @@ -136,8 +137,9 @@ impl<'a> Walker<'a> { at_do: Option<(usize, usize)>, ) -> Result<(), AuditError> { let mut path = Path::Empty; - for (i, op) in ops.iter().enumerate() { + for (i, op) in ops.enumerate() { self.bump()?; + let op = &op; // Paint position of this operator: its own `(piece, joined index)` at page level, // the enclosing `Do`'s position inside a form XObject. let pos = match at_do { @@ -257,8 +259,9 @@ impl<'a> Walker<'a> { /// changes inside never leak out; every rectangle found is stamped with the `Do` /// position `pos`. fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> { - let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let Some(entry) = xobjects.get(name) else { return Ok(()) }; + // Borrowed, not copied: a `Do` must not cost the size of the whole XObject table. + let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)); + let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) }; let resolved = self.doc.resolve(entry); let Object::Stream(stream) = &*resolved else { return Ok(()) }; let dict = &stream.dict; @@ -274,18 +277,18 @@ impl<'a> Walker<'a> { (v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]])) }) .unwrap_or(Matrix::IDENTITY); - let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new()); + let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty); if !self.budget.enter_form() { return Err(AuditError::TooManyForms); } // A form stream that cannot be decoded fails the audit: it might hide a cover. let data = self.budget.decode_strict(stream)?; - let ops = parse(&data).ops; // The XObject matrix maps form space into the user space active at `Do`. let mut sub_gs = gs.clone(); sub_gs.ctm = matrix.then(&gs.ctm); let mut sub_stack = Vec::new(); - self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos)) + self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos)) } } @@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> { /// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque. fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 { - let alpha = resources - .get(b"ExtGState") - .map(|o| doc.resolve(o)) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(name).map(|o| doc.resolve(o))) - .and_then(|o| o.as_dict().cloned()) - .and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); + // Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table. + let states = resources.get(b"ExtGState").map(|o| doc.resolve(o)); + let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o)); + let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64())); alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0) } @@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi } } let pieces = Pieces::join(&streams); - let parsed = pieces.parse(); let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget }; let mut gs = Gs::default(); let mut stack = Vec::new(); - w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?; + // Read one operator at a time: the joined stream can hold millions of them within the + // decode budget, and the walk only ever needs the current one. + w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?; Ok(w.out) } diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f2bcf63358..97429d4c40 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4; const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; +/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan +/// is far cheaper than a box comparison, but a long padded line under many covers adds up. +const TEXT_BYTES_PER_COMPARISON: usize = 16; /// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any /// content that cannot be decoded, or that needs more work or report than the audit's bounds, is @@ -157,15 +160,24 @@ impl Budget { cover: &[f64; 4], hides: impl Fn(&TextLine) -> bool, ) -> Result, AuditError> { - self.comparisons = self.comparisons.saturating_add(self.page_cost); - if self.comparisons > self.limits.comparisons { - return Err(AuditError::TooManyComparisons); - } + self.spend(self.page_cost)?; if !copying { - return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new)); + let mut hidden = false; + for line in lines.iter().filter(|l| hides(l)) { + // Trimming walks the line's text (padding and all): it costs its bytes. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; + if !line.text.trim().is_empty() { + hidden = true; + break; + } + } + return Ok(hidden.then(String::new)); } let mut covered = String::new(); for line in lines.iter().filter(|l| hides(l)) { + // Copying and trimming the line walks its text once per cover: a long padded + // line under many covers would otherwise be read far more than its runs suggest. + self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?; if !covered.is_empty() { covered.push(' '); } @@ -174,11 +186,21 @@ impl Budget { Ok((!covered.trim().is_empty()).then_some(covered)) } + /// `n` units of work done against the comparison bound. + fn spend(&mut self, n: usize) -> Result<(), AuditError> { + self.comparisons = self.comparisons.saturating_add(n); + if self.comparisons > self.limits.comparisons { + return Err(AuditError::TooManyComparisons); + } + Ok(()) + } + /// The text a finding reports for `covered`: none when copying is withheld. Charges what it - /// reports against the output bound. + /// reports, and the finding's own record, against the output bound: a finding with no text + /// (copying withheld, or a shared annotation list on many pages) still takes memory. fn report_text(&mut self, covered: String, copying: bool) -> Result { let reported = if copying { covered } else { String::new() }; - self.covered_text = self.covered_text.saturating_add(reported.len()); + self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::()); if self.covered_text > self.limits.covered_text { return Err(AuditError::TooMuchOutput); } @@ -321,17 +343,14 @@ enum AnnotationCover { fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); - let items: Vec = match &*resolved { - pdfcraft_cos::Object::Array(items) => items.clone(), - // A missing /Annots means no marks; a malformed one fails the audit. - _ => return Err(AuditError::BadAnnots), - }; + // A missing /Annots means no marks; a malformed one fails the audit. + let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) }; let mut out = Vec::new(); - for a in &items { - let dict = match &*doc.resolve(a) { - pdfcraft_cos::Object::Dict(d) => d.clone(), - _ => continue, - }; + for a in items { + // Borrowed from the resolved object: one large dictionary listed many times is not + // copied once per listing. + let resolved = doc.resolve(a); + let Some(dict) = resolved.as_dict() else { continue }; let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), @@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro continue; } let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; - match subtype(doc, &dict)?.as_deref() { + match subtype(doc, dict)?.as_deref() { Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), Some(b"Square") => { let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); @@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro } let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); if alpha >= 1.0 - && let Some(rgb) = annotation_color(doc, &dict, b"IC") + && let Some(rgb) = annotation_color(doc, dict, b"IC") { out.push(AnnotationCover::Square { rect, rgb }); } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index e5fb9ab683..074eb39f66 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() { let doc = open(one_page(&content, "")); let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); - let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + // Plus the hidden line's text, read once to copy it. + let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON; + let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX }; assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); - let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX }; assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); } @@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() { let findings = audit_redactions(&user).expect("audit must run"); assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}"); assert_eq!(findings[0].covered_text, ""); - // The output bound counts the text reported, so withheld text costs nothing. - let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 }; + // The output bound counts the text reported and the finding's record, so withheld text + // costs only the record. + let record = std::mem::size_of::(); + let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record }; assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1); + let none = crate::Limits { covered_text: record - 1, ..nothing }; + assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput))); let owner = Document::open_with_password(bytes, Some("owner")).expect("open"); let findings = audit_redactions(&owner).expect("audit must run"); assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].covered_text, "SECRET DATA 123"); - let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() }; + let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record }; assert!(crate::audit(&owner, exact).is_ok()); let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact }; assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput))); @@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() { let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect(); assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms))); } + +/// Copying a covered line's text walks all of it, padding included, so a long padded line under +/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit). +#[test] +fn covered_text_bytes_count_against_the_comparison_bound() { + // One run: 20 KiB of spaces around a word, which trims to the word alone. + let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec(); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b"SECRET"); + content.extend_from_slice(&[b' '; 10_000]); + content.extend_from_slice(b") Tj ET\n0 g\n"); + for _ in 0..10 { + content.extend_from_slice(b"-1 688 1000000 32 re f\n"); + } + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.len(), 1); + let bytes = lines[0].text.len(); + assert!(bytes > 20_000, "{bytes} bytes of text"); + // Each cover: one comparison for the line, plus its bytes when it is hidden. + let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX }; + let findings = crate::audit(&doc, exact).expect("within the budget"); + assert_eq!(findings.len(), 10, "{findings:?}"); + assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}"); + let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons))); +} + +/// One annotation list shared by many pages makes a finding on each: every finding's record +/// counts against the output bound, text or no text (CodeRabbit). +#[test] +fn every_finding_record_counts_against_the_output_bound() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add_stream(content); + b.add(FONT); + b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>"); + let doc = open(b.build()); + let one = "SECRET".len() + std::mem::size_of::(); + let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one }; + assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3); + let short = crate::Limits { covered_text: 3 * one - 1, ..exact }; + assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput))); +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 92fd2775c9..8ffa2d0b3e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in - **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit. - **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`. +`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance. + ## API ```rust diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index 295502215a..5ae3d4fe46 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -6,6 +6,13 @@ impl Automation { let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?; + let html_mode = a.opt_str("html_mode")?.unwrap_or("rich"); + let format = match (format, html_mode) { + (pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html, + (pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple, + (_, "rich") => format, + (_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())), + }; let export = self.doc_to_copy(a)?.export_office_checked(format); write_atomic(&path, &export.bytes)?; // Text left out to keep a hostile page bounded is said, not silent (#3120). diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs new file mode 100644 index 0000000000..3cd1d7849e --- /dev/null +++ b/crates/automation/src/bates.rs @@ -0,0 +1,214 @@ +use std::io::Write; + +use pdfcraft_engine::{Edit, HeaderFooter}; +use serde_json::{Value, json}; + +use super::error::{Result, ToolError, failed}; +use super::json::Args; +use super::paths::read_input; +use super::{Arc, Automation, Path, PathBuf}; + +struct FilePlan { + input: String, + source: PathBuf, + password: Option, + output: PathBuf, + pages: usize, + first: u64, + last: u64, +} + +impl Automation { + pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result { + let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?; + if files.is_empty() || files.len() > 1000 { + return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into())); + } + let start = match args.0.get("start_number") { + None | Some(Value::Null) => 1, + Some(value) => value + .as_u64() + .filter(|n| *n >= 1) + .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?, + }; + let digits = args.opt_int("digits")?.unwrap_or(6); + let digits = + u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?; + let prefix = args.opt_str("prefix")?.unwrap_or(""); + let suffix = args.opt_str("suffix")?.unwrap_or(""); + // `#` separates the token's fields and `>>` ends it: either would cut the token short. + if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) { + return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into())); + } + let dry_run = args.opt_bool("dry_run")?.unwrap_or(false); + + // Resolve and reserve the complete path list before opening or writing anything. + let mut sources = Vec::with_capacity(files.len()); + let mut targets = Vec::with_capacity(files.len()); + let mut passwords = Vec::with_capacity(files.len()); + for entry in files { + let input = entry + .get("input") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?; + let output = entry + .get("output") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?; + let source = self.resolve(input, false)?; + let target = self.resolve(output, true)?; + if target.exists() { + return Err(failed(format!("output already exists; refusing to replace it: {output}"))); + } + let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?; + if !parent.is_dir() { + return Err(failed(format!("output directory does not exist: {}", parent.display()))); + } + sources.push((input.to_string(), source)); + targets.push((output.to_string(), target)); + passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned)); + } + let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect(); + let mut output_paths = std::collections::HashSet::new(); + for (name, path) in &targets { + if input_paths.contains(path) { + return Err(failed(format!("output {name} names an input file; choose a separate output path"))); + } + if !output_paths.insert(path.clone()) { + return Err(failed(format!("two files use the same output path: {name}"))); + } + } + + let mut plans = Vec::with_capacity(files.len()); + let mut next = start; + let mut input_bytes = 0u64; + for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() { + let size = std::fs::metadata(input_path).map_err(failed)?.len(); + input_bytes = input_bytes + .checked_add(size) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let bytes = read_input(input_path)?; + input_bytes = input_bytes + .saturating_sub(size) + .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)) + .filter(|total| *total <= 512 * 1024 * 1024) + .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?; + let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let password = passwords.get(index).and_then(Option::as_deref); + let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?; + let pages = + self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?; + self.session.close(id); + if pages == 0 { + return Err(failed(format!("{input_name} has no pages to number"))); + } + let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?; + let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?; + plans.push(FilePlan { + input: input_name.clone(), + source: input_path.clone(), + password: passwords.get(index).cloned().flatten(), + output: output_path.clone(), + pages, + first: next, + last, + }); + if index + 1 < sources.len() { + next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?; + } + } + + let intervals: Vec = plans + .iter() + .zip(&targets) + .map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last})) + .collect(); + if dry_run { + return Ok(json!({"dry_run":true,"files":intervals})); + } + + let mut staged = Vec::with_capacity(plans.len()); + let mut results = Vec::with_capacity(plans.len()); + let mut output_bytes = 0u64; + for plan in &plans { + let source_bytes = match read_input(&plan.source) { + Ok(bytes) => bytes, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref()) + { + Ok(id) => id, + Err(error) => { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + continue; + } + }; + if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) { + self.session.close(id); + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"})); + continue; + } + let template = format!("<>", plan.first); + let mut settings = HeaderFooter::default(); + settings.text[4] = template; + let pages: Vec = (0..plan.pages).collect(); + let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false }); + let result = applied.and_then(|()| self.session.save_full_bytes(id)); + match result { + Ok(bytes) => { + let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX)); + if total > 512 * 1024 * 1024 { + results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"})); + } else { + output_bytes = total; + staged.push((plan.output.clone(), bytes)); + } + } + Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})), + } + self.session.close(id); + } + for (path, bytes) in staged { + match publish_new(&path, &bytes) { + Ok(()) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages})); + } + } + Err(error) => { + let plan = plans.iter().find(|p| p.output == path); + if let Some(plan) = plan { + results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})); + } + } + } + } + results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX)); + let completed = results.iter().filter(|r| r["status"] == "ok").count(); + let failed_count = results.len().saturating_sub(completed); + Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results})) + } +} + +/// Publish a PDF atomically while refusing a target created by another process after preflight. +fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?; + temp.write_all(bytes).map_err(failed)?; + temp.as_file().sync_all().map_err(failed)?; + temp.persist_noclobber(path).map(|_| ()).map_err(|error| { + if error.error.kind() == std::io::ErrorKind::AlreadyExists { + failed(format!("output appeared after preflight; refusing to replace it: {}", path.display())) + } else { + failed(error.error) + } + }) +} diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 5dbd5b8542..ae46be87d0 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -539,8 +539,10 @@ impl Automation { return self.apply(a, edit); } let text_at = |t: &str| { - let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0); - let h = size * 1.2 + 6.0; + let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max); + let w = (longest + 8.0).clamp(20.0, 600.0); + let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1); + let h = rows as f64 * size * 1.2 + 6.0; let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]); Shape::Typewriter { rect, font_size: size } }; diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 49d4e6fd4d..f3c51d22e0 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -53,6 +53,7 @@ impl Automation { tools::check_args(def, args)?; let a = Args(args); let out = match name { + "doc_bates_multi" => self.doc_bates_multi(&a)?, "doc_open" => self.doc_open(&a)?, "doc_batch" => self.doc_batch(&a)?, "doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }), diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1910867c9..5bcb032fce 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -16,6 +16,7 @@ mod a11y; mod batch; +mod bates; mod comments; mod content; mod conventions; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ba5892a451..4672e49886 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,6 +121,14 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ + t("doc_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.") + .destructive() + .with(schema(json!({ + "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}}, + "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."}, + "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."}, + "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."} + }), &["files"])), t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, @@ -1194,9 +1202,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.") .cmd("export.docx") - .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), + .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") .ro() .cmd("standards.pdfa") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index c9b50755e8..13f9af4371 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2832,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); } +#[test] +fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() { + let dir = workdir("bates-multi"); + let files = json!([ + {"input":"b.pdf","output":"b-numbered.pdf"}, + {"input":"a.pdf","output":"a-numbered.pdf"} + ]); + let mut a = auto(&dir); + let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true})); + assert_eq!(allocation["files"][0]["first"], 50); + assert_eq!(allocation["files"][0]["last"], 51); + assert_eq!(allocation["files"][1]["first"], 52); + assert_eq!(allocation["files"][1]["last"], 54); + assert!(!dir.join("a-numbered.pdf").exists()); + + let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"})); + assert_eq!(applied["completed"], 2); + let mut out = auto(&dir); + let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap(); + let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap(); + assert!(page_text(&mut out, second)[0].contains("CASE-000017")); + assert!(page_text(&mut out, first)[0].contains("CASE-000019")); + + let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX})); + assert!(overflow.to_string().contains("overflows")); + assert!(!dir.join("overflow.pdf").exists()); + // A range may end at u64::MAX itself. + let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true})); + assert_eq!(at_max["files"][0]["last"], u64::MAX); + for bad in + [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})] + { + let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true}); + args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}"); + } + + use pdfcraft_engine::{Changes, Edit, Protection, Session}; + let mut session = Session::new(); + let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap(); + session + .apply( + id, + Edit::Protect(Protection { + open_password: Some("open".into()), + permissions_password: Some("owner".into()), + changes: Changes::None, + ..Default::default() + }), + ) + .unwrap(); + std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap(); + let result = ok( + &mut a, + "doc_bates_multi", + json!({"files":[ + {"input":"b.pdf","output":"partial-numbered.pdf"}, + {"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"} + ]}), + ); + assert_eq!(result["completed"], 1); + assert_eq!(result["failed"], 1); + assert_eq!(result["files"][1]["first"], 3); + assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}"); + assert!(dir.join("partial-numbered.pdf").exists()); + assert!(!dir.join("locked-numbered.pdf").exists()); +} + #[test] fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { let dir = workdir("hf-shrink"); @@ -7615,6 +7683,17 @@ fn exporting_to_word_html_and_rtf() { } let html = std::fs::read_to_string(dir.join("a.html")).unwrap(); assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("
").count() == 2, "{html}"); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "simple.html", "html_mode": "simple" })); + let simple_html = std::fs::read_to_string(dir.join("simple.html")).unwrap(); + assert!( + simple_html.contains("Page 1") + && !simple_html.contains("= 66.0, "five text lines need their full height, including blank lines: {:?}", annot.rect); + let pixels = a.render_pixels(doc, 1, 1.0).unwrap(); + // The fixture's page text is at y=150; only the typewriter draws at y=65..80. + assert!( + (65..80).any(|y| (20..80).any(|x| { + let i = (y * pixels.0 as usize + x) * 4; + pixels.2[i..i + 3].iter().all(|v| *v < 100) + })), + "the lower nonempty line appears inside its box" + ); + let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"multiline.pdf"})); + let mut re = auto(&dir); + let reopened = ok(&mut re, "doc_open", json!({"path":"multiline.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(re.session().get(pdfcraft_engine::DocId(reopened)).unwrap().info.annotations[0].contents.as_deref(), Some(text)); + assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical"); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 082d1910d1..2aaf33ec63 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -27,7 +27,7 @@ pub(crate) use state::ObjectReader; use state::{ObjStm, Slot}; pub use state::{ReadState, SourceIdentity}; #[cfg(test)] -use state::{SECTION_READS, objstm_member, owned_bytes}; +use state::{SECTION_READS, held_bytes, objstm_member}; use xref::XrefIndex; pub use xref::{Revision, XrefEntry}; @@ -47,7 +47,7 @@ pub struct Document { /// `fill_xref_gaps`. located: Arc>, repair_log: Arc>, - cache: Arc>>>, + cache: Arc>, objstms: Arc>>>, /// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan /// share them, and each would otherwise decode them again (see `Document::jbig2_globals`). diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ac18cff03..4efb185f1a 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,5 +1,5 @@ use super::reader::find; -use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; +use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member}; use super::xref::{Revision, XrefEntry}; use super::{ Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared, @@ -399,8 +399,9 @@ impl Document { let obj = Arc::new(obj); // The editing cache is bounded like the scan reader's: re-parsing a file-backed view // is cheap, and returned objects outlive eviction. - self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone()); - self.evict_editor_cache_if_over_budget(num, &obj)?; + let owned = held_bytes(&obj, &self.data, 0); + self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned); + self.evict_editor_cache_if_over_budget(num)?; Ok(obj) } @@ -409,10 +410,10 @@ impl Document { /// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes /// count; file-backed views re-parse for free. /// - /// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` × - /// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full - /// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. - /// The count cap always holds; byte accounting between passes is approximate. + /// Worst case per document: input bytes + `EDITOR_CACHE_BYTES` (32 MB) of owned object + /// data + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. Each object is measured + /// once, as it is cached ([`ObjectCache`](super::state::ObjectCache)), so both caps hold + /// after every insertion. /// Clones share these caches, so one clone's eviction drops another's hot set; /// that only costs re-parsing, never correctness. Eviction keeps just the newcomer: /// file-backed views re-parse cheaply and editor working sets stay far below 1024. @@ -425,37 +426,13 @@ impl Document { const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024; - /// Newcomers above this force a full accounting pass even below the count cap: - /// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the - /// byte budget can double. - const EDITOR_LARGE_OBJECT: usize = 256 * 1024; - - fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> { - // Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer - // below the count cap. `len` is copied out first; nothing here holds the lock. - let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len(); - if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT { - return Ok(()); - } - // Walk a snapshot off-lock; re-lock only to evict. - let live: Vec<(u32, Arc)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect(); - let data = &self.data; - let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0))); - if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } - // Re-check under the final lock: another thread may have inserted or evicted since - // the snapshot. Keep its newcomers, and skip the eviction if back within budget. - let snapshot: std::collections::HashSet = live.into_iter().map(|(n, _)| n).collect(); + fn evict_editor_cache_if_over_budget(&self, fresh_num: u32) -> Result<(), CosError> { let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?; - if cache.len() <= Self::EDITOR_CACHE_OBJECTS { - let data = &self.data; - let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))); - if owned <= Self::EDITOR_CACHE_BYTES { - return Ok(()); - } + if cache.len() <= Self::EDITOR_CACHE_OBJECTS && cache.owned() <= Self::EDITOR_CACHE_BYTES { + return Ok(()); } - cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n)); + // Eviction keeps just the newcomer: file-backed views re-parse cheaply. + cache.retain(|n| *n == fresh_num); Ok(()) } diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index b4323f982e..96dde01c0f 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -163,11 +163,10 @@ impl ObjectReader { // input bytes, which the document keeps anyway, so they don't count; decrypted // streams and strings do. Checked after failed loads too: resolving a /Length may // have cached large objects. - if let Ok(mut cache) = self.document.cache.lock() { - let data = &self.document.data; - if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES { - cache.clear(); - } + if let Ok(mut cache) = self.document.cache.lock() + && (cache.len() >= Self::OBJECTS || cache.owned() > Self::STREAM_BYTES) + { + cache.clear(); } object } @@ -179,26 +178,96 @@ thread_local! { pub(super) static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object +/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes +/// in, and their sum kept beside them. +/// +/// Both caches are bounded by those bytes. Summing them on every read walked every cached object +/// whole each time: a file whose page dictionaries carry kilobytes of private data (InDesign's +/// `/PieceInfo`) took seconds to open, almost all of it re-measuring objects already measured +/// (#5873). +#[derive(Default)] +pub(crate) struct ObjectCache { + entries: HashMap, usize)>, + owned: usize, +} + +impl ObjectCache { + pub(crate) fn get(&self, num: &u32) -> Option<&Arc> { + self.entries.get(num).map(|(object, _)| object) + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, num: &u32) -> bool { + self.entries.contains_key(num) + } + + /// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it). + pub(crate) fn insert(&mut self, num: u32, object: Arc, owned: usize) { + if let Some((_, before)) = self.entries.insert(num, (object, owned)) { + self.owned = self.owned.saturating_sub(before); + } + self.owned = self.owned.saturating_add(owned); + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// The bytes the cached objects hold between them. + pub(crate) fn owned(&self) -> usize { + self.owned + } + + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.owned = 0; + } + + pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) { + self.entries.retain(|num, _| keep(num)); + self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned)); + } + + #[cfg(test)] + pub(crate) fn values(&self) -> impl Iterator> { + self.entries.values().map(|(object, _)| object) + } +} + +/// Nesting [`held_bytes`] walks: past the parser's own nesting limit, so every parsed object /// is measured in full. const OWNED_BYTES_DEPTH: usize = 256; -/// Bytes `object` holds of its own: string and stream data that is not a view into `data` -/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured -/// and counts as too large to keep. -pub(super) fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { +/// What keeping `object` parsed costs: string and stream data that is not a view into `data` +/// (the input file), and the parsed tree around it -- an [`Object`] per value and a key per +/// dictionary entry. An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured and +/// counts as too large to keep. +/// +/// The tree counts. Measured by its strings and streams alone, a page dictionary carrying +/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of +/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB +/// (#5873). +pub(super) fn held_bytes(object: &Object, data: &Arc>, depth: usize) -> usize { if depth > OWNED_BYTES_DEPTH { return usize::MAX; } + let node = std::mem::size_of::(); + // A key, its slot beside the value, and room in the index a large dictionary keeps. + let entry = |key: &[u8]| key.len().saturating_add(std::mem::size_of::<(crate::Name, Object)>()).saturating_add(16); + let dict = + |d: &crate::Dict, start: usize| d.iter().fold(start, |n, (k, v)| n.saturating_add(entry(k)).saturating_add(held_bytes(v, data, depth + 1))); match object { - Object::String(s) => s.bytes.len(), - Object::Stream(s) => { - let own = if s.raw.shares(data) { 0 } else { s.raw.len() }; - s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))) - } - Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))), - Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))), - _ => 0, + Object::String(s) => node.saturating_add(s.bytes.len()), + Object::Name(n) => node.saturating_add(n.len()), + Object::Stream(s) => dict(&s.dict, node.saturating_add(if s.raw.shares(data) { 0 } else { s.raw.len() })), + Object::Array(a) => a.iter().fold(node, |n, v| n.saturating_add(held_bytes(v, data, depth + 1))), + Object::Dict(d) => dict(d, node), + _ => node, } } diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index cca4b87326..4c7b3eb652 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1109,7 +1109,8 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() { // Data of its own (decrypted streams, strings) counts: past the budget the cache is // cleared after the next read, including a failed one. let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1])); - reader.document.cache.lock().unwrap().insert(3, Arc::new(owned)); + let size = held_bytes(&owned, &reader.document.data, 0); + reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size); assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null); assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted"); } @@ -1192,13 +1193,14 @@ fn editor_cache_counts_deeply_nested_strings() { source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap(); let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap()); let doc = Document::open(bytes).unwrap(); - assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20); + let held = held_bytes(&nested, &doc.data, 0); + assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}"); let root = doc.get(doc.root().unwrap()); for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() { let Object::Ref(reference) = reference else { panic!("indirect object") }; drop(doc.get(*reference)); let cache = doc.cache.lock().unwrap(); - let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0))); + let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); let strings = cache.values().filter(|o| o.as_array().is_some()).count(); assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted"); } @@ -1240,8 +1242,8 @@ fn editor_cache_stays_bounded() { #[test] fn editor_cache_byte_bound_fires() { - // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each - // newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass. + // 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is + // checked after every insertion. let mut source = Document::new_empty(); let objects: Vec<_> = (0..40) .map(|i| { @@ -1701,3 +1703,50 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() { }); rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially"); } + +/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has +/// to measure the cache again on every read (#5873). +#[test] +fn the_object_cache_keeps_its_total_as_objects_come_and_go() { + let data = Arc::new(Vec::new()); + let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100]))); + let two = Arc::new(Object::Array(vec![Object::Int(1); 10])); + let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0)); + let mut cache = super::state::ObjectCache::default(); + cache.insert(1, one.clone(), a); + cache.insert(2, two, b); + assert_eq!(cache.owned(), a + b); + cache.insert(2, one, a); + assert_eq!(cache.owned(), a + a, "a replaced object no longer counts"); + cache.retain(|n| *n == 1); + assert_eq!((cache.len(), cache.owned()), (1, a)); + cache.clear(); + assert_eq!((cache.len(), cache.owned()), (0, 0)); +} + +/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands +/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing +/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree +/// holds counts now, so the byte budget bounds them. +#[test] +fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() { + let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect(); + let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>"); + let count = 300; + let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect(); + let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")]; + bodies.extend((0..count).map(|_| dict.clone())); + let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect(); + let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap(); + let one = doc.get(crate::ObjRef::new(2, 0)); + let held = held_bytes(&one, &doc.data, 0); + assert!(held > 2000 * std::mem::size_of::(), "the parsed tree counts: {held} bytes"); + for i in 0..count { + drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0))); + } + let cache = doc.cache.lock().unwrap(); + assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned()); + assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len()); + let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0))); + assert_eq!(cache.owned(), sum, "the kept total is the cache's own"); +} diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 1e9b45ad61..709659f537 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -341,7 +341,11 @@ impl<'a> Lexer<'a> { let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?; if s.contains('.') { // Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix. - let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1); + // Only a doubled sign needs a cleaned copy: a page of InDesign `/PieceInfo` is + // thousands of plain reals, and building a copy and a substring search for each + // was a large part of reading one (#5873). + let t = s.trim_start_matches('+'); + let cleaned = if t.as_bytes().windows(2).any(|w| w == b"--") { std::borrow::Cow::Owned(t.replacen("--", "-", 1)) } else { t.into() }; let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok()); return Ok(Object::Real(v.unwrap_or(0.0))); } @@ -707,4 +711,13 @@ mod tests { let mut lx = Lexer::new(two.as_bytes(), 0); assert!(lx.object().is_ok() && lx.object().is_ok()); } + + /// A plain real is read without a cleaned copy of its token; the forms that need one read + /// as they did (#5873). + #[test] + fn reals_read_the_same_with_and_without_a_doubled_sign() { + for (token, want) in [("1.5", 1.5), ("-375.993", -375.993), ("+2.25", 2.25), ("--5.5", -5.5), ("1.2.3", 1.2), ("-.5", -0.5), ("0.0", 0.0)] { + assert_eq!(Lexer::new(token.as_bytes(), 0).object().unwrap(), Object::Real(want), "{token}"); + } + } } diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 24512b566b..9be1954b09 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -140,6 +140,8 @@ pub enum OfficeFormat { Docx, Pptx, Html, + /// CSS-free HTML that preserves text and inline images but drops colours and layout styling. + HtmlSimple, Rtf, Markdown, } @@ -150,6 +152,7 @@ impl OfficeFormat { OfficeFormat::Docx => "docx", OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", + OfficeFormat::HtmlSimple => "html", OfficeFormat::Rtf => "rtf", OfficeFormat::Markdown => "md", } @@ -301,6 +304,7 @@ impl crate::Document { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), + OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index ae8842ddfb..074adfc82d 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -55,7 +55,10 @@ impl Document { if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 { return Err(SignatureImageError::PlacedSize.to_string()); } - let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?; + // Decoded within an allowance sized for the small picture a signature is, not the + // document's: a stream that inflates past it is refused before it is held whole. + let (_, bytes) = + pdfcraft_create::image_file_within(&editor.cos, picture, &mut pdfcraft_cos::ImageBudget::with_work(SIGNATURE_IMAGE_WORK))?; let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; let opacity = pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; @@ -90,6 +93,9 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti /// Saved signatures live in settings, so both source and normalized PNG are bounded. pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20; const MAX_PIXELS: u64 = 4 << 20; +/// Steps of decoding the signature's picture may take: every stage of its filters, for a +/// picture of at most [`MAX_PIXELS`] samples, fits in it many times over. +const SIGNATURE_IMAGE_WORK: u64 = 1 << 28; const MAX_SIDE: u32 = 4096; /// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down. const MAX_SOURCE_SIDE: u32 = 8192; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d60ed04d66..a77e471a7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10251,3 +10251,20 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() { assert_eq!(s.get(id).unwrap().bytes, before); } } + +#[test] +fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() { + let (mut session, id) = rotated_blank(0); + let text = "\nAda\n\nLovelace\n"; + let shape = Shape::Typewriter { rect: text_box_at(0, [20.0, 250.0], 120.0, 66.0).unwrap(), font_size: 10.0 }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: text.into(), author: "Test".into() })).unwrap(); + assert_eq!(session.get(id).unwrap().info.annotations[0].contents.as_deref(), Some(text), "engine keeps outer typewriter line breaks"); + let before = render_page(&session, id); + assert!(before.rgba.as_chunks::<4>().0.iter().any(|p| p[..3] == [0, 0, 0]), "the appearance contains ink"); + let bytes = session.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let again = reopened.open("multiline.pdf", None, bytes, None).unwrap(); + assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines"); + assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical"); +} diff --git a/crates/export/src/as_html.rs b/crates/export/src/as_html.rs index 7aeea595a8..3bc6709e69 100644 --- a/crates/export/src/as_html.rs +++ b/crates/export/src/as_html.rs @@ -38,11 +38,23 @@ pub(crate) fn base64(data: &[u8]) -> String { /// One HTML file: headings and paragraphs, tables as real `` elements, images inline, a /// rule between pages. pub fn html(pages: &[Page], title: &str) -> String { + html_with_mode(pages, title, false) +} + +/// A simple HTML export with no CSS; page layout and colours are omitted. +pub fn html_simple(pages: &[Page], title: &str) -> String { + html_with_mode(pages, title, true) +} + +fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String { let mut s = format!( - "\n\n\n\n{}\n\n\n\n", - esc(title) + "\n\n\n\n{}\n{}\n\n\n", + esc(title), + if simple { + "" + } else { + "" + } ); for it in items(pages) { match it { @@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi } // The paragraph has its first run's colour; a run in another colour says so. let colour = rgb8(r.block.color); - if colour != rgb8(first.color) { + if !simple && colour != rgb8(first.color) { let [red, green, blue] = colour; run = format!("{run}"); } t.push_str(&run); } - let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; match lvl { 0 => s.push_str(&format!("{t}

\n")), l => s.push_str(&format!("{t}\n")), @@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) }; s.push_str(&format!("{body}")); } s.push_str("\n"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index db53ee59c8..2b82919bbf 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -27,9 +27,9 @@ pub(crate) use as_docx::hex; #[cfg(test)] use as_docx::{MAX_MEDIA, docx_table}; pub(crate) use as_html::esc; -pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_html::{html, html_simple}; pub use as_markdown::markdown; #[cfg(test)] use as_markdown::{emphasis, paragraph}; diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 27cd8264e0..f630d98d5d 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() { assert_eq!(base64(b"Ma"), "TWE="); } +#[test] +fn simple_html_has_no_css_or_layout_elements() { + let mut p = page(); + let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0); + unicode.bold = true; + unicode.italic = true; + p.blocks.push(unicode); + let source = html_simple(&[p, table_page()], "Simple export"); + for banned in ["", "", "", " Inspector<'a> { } pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option { - let s = trimmed_text(self.resolve(d.get(key).ok()?))?; - (!s.is_empty()).then_some(s) + // Typewriter outer whitespace belongs to the text (keep in step with annot's summaries). + let s = if key == b"Contents" + && self.name(d, b"Subtype").as_deref() == Some("FreeText") + && self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter") + { + match self.resolve(d.get(key).ok()?) { + Object::String(bytes, _) => text_string(bytes).trim_matches('\0').to_owned(), + Object::Name(bytes) => String::from_utf8_lossy(bytes).trim_matches('\0').to_owned(), + _ => return None, + } + } else { + trimmed_text(self.resolve(d.get(key).ok()?))? + }; + (!s.trim().is_empty()).then_some(s) } - /// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims + /// Whitespace-trimmed metadata, spent from `budget` as [`spend`] does, so text that trims /// short or to nothing still spends its raw length. pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option { let o = self.resolve(d.get(key).ok()?); diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index a5bfec474d..7221e97d5a 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -1870,3 +1870,45 @@ trailer << /Root 1 0 R >> assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width); assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height); } + +/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it +/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every +/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what +/// inspection keeps, and what inspection reports is the same as for the page without it. +#[test] +fn inspection_keeps_no_private_page_data() { + use lopdf::dictionary; + let make = |private: bool| -> Vec { + let mut doc = Document::with_version("1.7"); + let pages_id = doc.new_object_id(); + let mut page = dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()], + "Rotate" => 90, + }; + if private { + let map: Dictionary = (0..50).map(|i| (i.to_string(), Object::Array(vec![Object::Integer(i), Object::Real(116.22)]))).collect(); + page.set("PieceInfo", dictionary! { "InDesign" => dictionary! { "PageItemUIDToLocationDataMap" => map } }); + } + let page_id = doc.add_object(page); + doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + doc.trailer.set("Root", catalog); + let mut out = Vec::new(); + doc.save_to(&mut out).unwrap(); + out + }; + let (with, without) = (make(true), make(false)); + let lazy = LazyStructure::new(Arc::new(with.clone()), None).unwrap(); + let page = lazy.get_object((2, 0)).unwrap().as_dict().unwrap(); + assert!(!page.has(b"PieceInfo"), "the private data is not kept"); + assert!(page.has(b"MediaBox") && page.has(b"Rotate") && page.has(b"Parent"), "the rest of the page is"); + + let report = |bytes: Vec| { + let mut info = inspect(Arc::new(bytes), None).unwrap(); + info.file_size = 0; + format!("{info:?}") + }; + assert_eq!(report(with), report(without), "what inspection reports does not change"); +} diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index a3adbf1ecb..9c5d75bfbc 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -8218,6 +8218,61 @@ fn japanese_substitute_spaces_keep_pdf_widths_and_unicode() { } } +/// One code, `<0010>`, in a Type 0 font named `base_font` that the PDF doesn't embed, with a +/// `/ToUnicode` that maps it to the UTF-16 `unicode`. 0x10 is the hyphen's glyph index in Arial, +/// and Identity-H with an identity `/CIDToGIDMap` makes the code that index, as Word writes it. +fn soft_hyphen_pdf(base_font: &str, unicode: &str) -> Vec { + let content = "BT /F1 60 Tf 10 20 Td <0010> Tj ET"; + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap +/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def +/CMapName /Adobe-Identity-UCS def /CMapType 2 def +1 begincodespacerange <0000> endcodespacerange +1 beginbfchar <0010> <{unicode}> endbfchar +endcmap CMapName currentdict /CMap defineresource pop end end" + ); + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 60 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /Identity-H /DescendantFonts [6 0 R] /ToUnicode 8 0 R >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> + /FontDescriptor 7 0 R /CIDToGIDMap /Identity /DW 1000 /W [16 [333]] >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 32 /FontBBox [-665 -325 2000 1040] /ItalicAngle 0 /Ascent 728 /Descent -210 /CapHeight 716 /StemV 80 >> endobj +8 0 obj << /Length {} >> stream +{cmap} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cmap.len() + ) + .into_bytes() +} + +/// #5888: Windows exporters map the hyphen glyph of an Arial they don't embed to U+00AD. The +/// standard substitute has no soft hyphen, so code 0x0010 was taken as its glyph 16 and drew +/// `Ë` (`!` in bold). It draws the substitute's hyphen now, and the text keeps the soft hyphen. +#[test] +fn a_soft_hyphen_a_substitute_lacks_draws_its_hyphen() { + for base_font in ["ArialMT", "Arial-BoldMT"] { + let soft = soft_hyphen_pdf(base_font, "00AD"); + let out = render_first_page(soft.clone()); + let expected = render_first_page(soft_hyphen_pdf(base_font, "002D")); + assert!(expected.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "{base_font}: the hyphen control must draw ink"); + assert!(out.rgba.as_ref() == expected.rgba.as_ref(), "{base_font}: a soft hyphen draws as the substitute's hyphen"); + let mut renderer = PageRenderer::new(Arc::new(soft), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let glyphs: String = text.text.unwrap().glyphs.iter().map(|glyph| glyph.text.as_str()).collect(); + assert_eq!(glyphs, "\u{ad}", "{base_font}: drawing the hyphen must not rewrite Unicode"); + } +} + /// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font /// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them /// (Shippori Mincho) draws them as .notdef. diff --git a/crates/render/src/structure.rs b/crates/render/src/structure.rs index bf9c16e23f..effbc87d92 100644 --- a/crates/render/src/structure.rs +++ b/crates/render/src/structure.rs @@ -187,9 +187,20 @@ fn convert_dict(dict: &pdfcraft_cos::Dict, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); } - dict.iter().map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))).collect::>>().map(|items| items.into_iter().collect()) + dict.iter() + .filter(|(key, _)| key.as_slice() != PRIVATE_DATA) + .map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))) + .collect::>>() + .map(|items| items.into_iter().collect()) } +/// `/PieceInfo`: an application's private data about a page, form or document (ISO 32000-1 +/// §14.5), left out of what inspection keeps. Nothing here reads it, and every object read is +/// kept converted for the inspection's lifetime: InDesign writes kilobytes of it into every page, +/// and a 1,412-page book held over a gigabyte of it (#5873). The file and the editing document are +/// untouched; this is the read-only view inspection walks. +const PRIVATE_DATA: &[u8] = b"PieceInfo"; + fn convert(object: &CosObject, depth: usize) -> Result { if depth > 100 { return Err(Error::RecursionLimit); diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index d3db335d83..08a20afb45 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -160,6 +160,9 @@ impl PdfCraftApp { pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; if let Ok(mut bar) = serde_json::from_value::(v["hotbar"].clone()) { + if v["hotbar"].get("placed").is_none() { + bar.adopt_legacy(); + } bar.normalize(); self.hotbar = bar; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3879cd9344..49d2c44b03 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -18,8 +18,8 @@ use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; mod area; -pub(super) mod autoscroll; -mod bars; +pub(crate) mod autoscroll; +pub(crate) mod bars; mod cache; pub(super) mod crop; mod find; @@ -253,6 +253,22 @@ impl ImageObb { } } +/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835). +pub(crate) fn crop_contains_point( + img: &pdfcraft_engine::PageImage, + crop: &crate::edit_text_ui::CropMode, + xf: &PageXform, + info: &DocInfo, + page: usize, + p: Pos2, +) -> bool { + let obb = ImageObb::from_image(img, xf, info, page); + let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page); + crate::edit_text_ui::quad_contains(obb.corners(), p) + || crate::edit_text_ui::quad_contains(full_obb.corners(), p) + || crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p) +} + /// The page display as it was before full screen, put back on leaving it /// ([`DocView::enter_presentation`]). #[derive(Clone, Copy, Debug, PartialEq)] @@ -2441,10 +2457,23 @@ fn draw_pages( let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; let images = cached_edit_images(view, doc, page_idx).to_vec(); + // Crop controls own the click even when another image overlaps them. + if view.image_selection.as_ref().is_some_and(|s| { + s.page == page_idx + && s.crop + .as_ref() + .zip(images.get(s.index)) + .is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p)) + }) { + over_image = true; + break; + } for (idx, img) in images.iter().enumerate().rev() { let obb = ImageObb::from_image(img, &xf, info, page_idx); let b = image_screen_rect(img, &xf, info, page_idx); - if obb.contains(b, p) { + let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some()); + let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) }; + if hit { over_image = true; if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx)); @@ -2557,6 +2586,22 @@ fn draw_pages( view.current = current; } resp.context_menu(|ui| { + let is_crop_over_image = tool == QuickTool::EditText + && view.image_selection.clone().is_some_and(|sel| { + let Some(ref crop) = sel.crop else { return false }; + let pointer_pos = ptr.pos; + let Some(p) = pointer_pos else { return false }; + let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; + let images = cached_edit_images(view, doc, sel.page).to_vec(); + let Some(img) = images.get(sel.index) else { return false }; + crop_contains_point(img, crop, &xf, info, sel.page, p) + }); + if is_crop_over_image { + ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); + egui::Popup::close_all(ui.ctx()); + ui.close(); + return; + } // Preparing a form: the selected field's menu. if preparing && let Some((name, wi)) = view.prepare.selected.clone() { // Several fields: Align, Center, Distribute, Set Fields to Same Size. @@ -4063,7 +4108,6 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, // The page nearest the left edge, whose margin the bar sits in. let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); quick_bar(app, avail, page_left, ui); - crate::hotbar::show(app, avail, ui.ctx()); } if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 5ea5e4a19c..dd3fe8c498 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -1,7 +1,4 @@ -use super::{ - Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme, - vec2, -}; +use super::{Color32, CornerRadius, DocInfo, DocView, PdfCraftApp, Pos2, Rect, Sense, SnapshotKind, Stroke, Tokens, icons, vec2}; /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { @@ -277,258 +274,23 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te } /// The bar's narrowest form: one 32 pt button, 6 pt margins and a 1 pt border each side. -const QUICK_BAR_WIDTH: f32 = 46.0; -const QUICK_BAR_EDGE: f32 = 14.0; +pub(crate) const QUICK_BAR_WIDTH: f32 = 46.0; +pub(crate) const QUICK_BAR_EDGE: f32 = 14.0; /// How far from the side panel the quick-action bar stands, given the width of the margin between /// the panel and the page and the bar's own width: centred in the margin when it holds the bar, /// else 14 pt in (a page filling the width). With no page on screen the caller passes a margin of 0, /// which is 14 pt too. -fn quick_bar_offset(margin: f32, width: f32) -> f32 { +pub(crate) fn quick_bar_offset(margin: f32, width: f32) -> f32 { // shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel. // `f32::min` returns the other operand for NaN, which would pass the fit check below. let margin = if margin.is_nan() { 0.0 } else { margin.min(100.0) }; if margin >= width + 2.0 * 8.0 { (margin - width) / 2.0 } else { QUICK_BAR_EDGE } } -/// The floating quick-action bar in the margin between the side panel and the page. +/// The unified quick-action bar and hotbar in the margin or docked to an edge. pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let mut offset = vec2(0.0, 0.0); - if app.hotbar.full_side { - match app.hotbar.dock.as_deref() { - Some("left") => offset.x += app.hotbar.button_size + 14.0, - Some("top") => offset.y += app.hotbar.button_size + 14.0, - _ => {} - } - } - let id = egui::Id::unique("quick-bar"); - // Last frame's bar: Fill & Sign's Text and Date tools add a wider row of controls to it. - let width = ui.ctx().memory(|m| m.area_rect(id)).map_or(QUICK_BAR_WIDTH, |bar| bar.width().max(QUICK_BAR_WIDTH)); - // The margin starts past a hotbar docked on the left. - let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left() - offset.x), width); - let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE) + offset; - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::NONE - .fill(t.card) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(12)) - .shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) }) - .inner_margin(egui::Margin::same(6)) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - ui.vertical(|ui| { - if icons::button(ui, "mouse-pointer-2", 32.0, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() { - app.quick_tool = QuickTool::Select; - } - if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() { - app.quick_tool = QuickTool::Hand; - } - // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a - // group selects its last-used tool; clicking it again opens the flyout. - for g in 0..comments::GROUPS.len() { - let current = app.comment_prefs.group_tool[g]; - let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); - let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label())); - if comments::GROUPS[g].len() == 1 { - if resp.clicked() { - app.execute(current.command()); - } - continue; - } - // A small corner triangle marks the flyout. - let r = resp.rect; - let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; - ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE)); - let open = (resp.clicked() && active) || resp.secondary_clicked(); - if resp.clicked() && !active { - app.execute(current.command()); - } - egui::Popup::menu(&resp) - .open_memory(open.then_some(egui::SetOpenCommand::Toggle)) - .align(egui::RectAlign::RIGHT_START) - .gap(6.0) - .show(|ui| { - for tool in comments::GROUPS[g] { - let on = app.quick_tool == QuickTool::Comment(*tool); - let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); - let press = theme::Press::track(ui, &click); - press.wash(ui, row, 4, on); - let body = row.translate(press.offset()); - icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); - let reserve = if on { 28.0 } else { 8.0 }; - crate::widgets::paint_left( - ui, - body.left_center() + vec2(34.0, 0.0), - tl!(tool.label()), - theme::regular(13.0), - t.text, - (row.width() - 34.0 - reserve).max(0.0), - ); - if on { - icons::paint( - ui, - Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), - "check", - 14.0, - t.accent, - ); - } - let click = theme::hand(click); - let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); - if click.clicked() { - app.execute(tool.command()); - ui.close(); - } - } - }); - } - // Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar) - // or the tool is in use. - if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText { - let on = app.quick_tool == QuickTool::EditText; - if icons::button(ui, "text-cursor-input", 32.0, on, tl!("Edit text & images")).clicked() { - if on { - app.put_down_edit_text(); - } else { - app.quick_tool = QuickTool::EditText; - } - } - } - // Fill & Sign ▸ (text, marks, date, signature). - let current_fill = match app.quick_tool { - QuickTool::Fill(f) => Some(f), - _ => None, - }; - let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); - let resp = icons::button( - ui, - if current_fill.is_some() { shown.icon() } else { "pen-line" }, - 32.0, - current_fill.is_some(), - tl!("Fill & Sign"), - ); - let r = resp.rect; - let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; - ui.painter().add(egui::Shape::convex_polygon( - tri.to_vec(), - if current_fill.is_some() { t.accent } else { t.text_faint }, - Stroke::NONE, - )); - if resp.clicked() && current_fill.is_none() { - app.execute(shown.command()); - } - let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked(); - egui::Popup::menu(&resp) - .open_memory(open.then_some(egui::SetOpenCommand::Toggle)) - .align(egui::RectAlign::RIGHT_START) - .gap(6.0) - .show(|ui| { - for tool in crate::fill_sign::FILL_TOOLS { - if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) { - continue; - } - let on = current_fill == Some(tool); - let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); - let press = theme::Press::track(ui, &click); - press.wash(ui, row, 4, on); - let body = row.translate(press.offset()); - icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); - let reserve = if on { 28.0 } else { 8.0 }; - crate::widgets::paint_left( - ui, - body.left_center() + vec2(34.0, 0.0), - tl!(tool.label()), - theme::regular(13.0), - t.text, - (row.width() - 34.0 - reserve).max(0.0), - ); - if on { - icons::paint( - ui, - Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), - "check", - 14.0, - t.accent, - ); - } - let click = theme::hand(click); - let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); - if click.clicked() { - app.execute(tool.command()); - ui.close(); - } - } - ui.separator(); - if let Some(command) = crate::fill_sign::signature_entries(ui, app, &t) { - app.execute(command); - ui.close(); - } - }); - if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { - ui.label(tl!("Font size:")); - let size = ui.add( - egui::DragValue::new(&mut app.fill_text_size) - .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) - .speed(0.25) - .suffix(" pt"), - ); - // #1274: the size also applies to the Fill & Sign text that is selected, - // once per change (a typed value, or the end of a drag), so a drag adds - // one undo step and not one per frame. - if ((size.changed() && !size.dragged()) || size.drag_stopped()) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) - }) - { - app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); - } - } - if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { - ui.menu_button(tl!("Colour"), |ui| { - if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { - app.fill_text_color = color; - ui.close(); - } - }); - } - if let QuickTool::Comment(tool) = app.quick_tool { - let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); - ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); - if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) - && let Some((index, id)) = app.active_ids() - && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) - && app.session.get(id).is_some_and(|doc| { - doc.allows_annotation() - && doc - .info - .annotations - .iter() - .any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool)) - }) - { - app.apply_edit(Edit::StyleAnnotation { - page, - index: annotation, - color: Some(color), - opacity: None, - width: None, - endings: None, - }); - } - } - }); - }); - }); + crate::hotbar::show(app, area, page_left, ui); } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 048d519222..84c36f41aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -20,6 +20,9 @@ pub(super) mod protect; pub(super) mod standards_ui; pub(super) mod word_count_ui; +#[cfg(test)] +mod tests; + pub(crate) use prompts::{PromptButton, save_prompt_key}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; @@ -1371,20 +1374,26 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin // (which only applies when the settings overflow it) opens the dialog at full size. // A scrollbar of its own: the floating one lies over the right edge of the theme // cards and other full-width rows (#3557). + preferences_scroll(ui, max, |ui| preferences::preferences_body(ui, app, &t)); + req.close = preferences::preferences_buttons(ui); + false +} + +fn preferences_scroll(ui: &mut egui::Ui, max: f32, add_contents: impl FnOnce(&mut egui::Ui)) { ui.scope(|ui| { ui.spacing_mut().scroll.floating = false; ui.spacing_mut().scroll.bar_inner_margin = 14.0; ui.spacing_mut().scroll.bar_outer_margin = 2.0; - ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25); + if ui.style().scroll_animation != egui::style::ScrollAnimation::none() { + ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25); + } egui::ScrollArea::vertical() .id_salt("preferences-dialog-scroll") .max_height(max) .min_scrolled_height(max) .animated(true) - .show(ui, |ui| preferences::preferences_body(ui, app, &t)); + .show(ui, add_contents); }); - req.close = preferences::preferences_buttons(ui); - false } /// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted. diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 38cce3274b..9e3182e416 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -22,14 +22,12 @@ pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; /// /// Draws a rounded container with options inside. The active option is highlighted /// with the accent colour. Emits AccessKit RadioButton widget info with the option's label. -#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool { let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect(); segmented_pill_choice_a11y(ui, t, current, &with_a11y) } /// A segmented pill selection bar with separate accessibility labels. -#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")] pub fn segmented_pill_choice_a11y(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool { segmented_pill_choice_a11y_query(ui, t, current, options, "").0 } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs new file mode 100644 index 0000000000..03f5c7c5b8 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -0,0 +1,35 @@ +use std::cell::Cell; +use std::rc::Rc; + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +#[test] +fn preferences_scroll_preserves_disabled_animation() { + for animated in [false, true] { + let scroll = Rc::new(Cell::new(false)); + let requested = scroll.clone(); + let mut h = Harness::builder().with_size(egui::vec2(600.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui(move |ui| { + super::preferences_scroll(ui, 200.0, |ui| { + ui.add_space(1000.0); + let target = ui.label("Scroll target"); + if requested.replace(false) { + ui.scroll_to_rect(target.rect, Some(egui::Align::BOTTOM)); + } + ui.add_space(300.0); + }); + }); + h.ctx.all_styles_mut(|style| { + style.scroll_animation = if animated { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() }; + }); + h.run_steps(4); + let before = h.get_by_label("Scroll target").rect().top(); + scroll.set(true); + h.run_steps(4); + let early = h.get_by_label("Scroll target").rect().top(); + h.run_steps(20); + let settled = h.get_by_label("Scroll target").rect().top(); + assert!((before - settled).abs() > 50.0, "the target must scroll: {before} -> {settled}"); + assert_eq!((early - settled).abs() > 1.0, animated, "animated: {animated}, early: {early}, settled: {settled}"); + } +} diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index f6a0c14c7f..c66b7e30b0 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -1,9 +1,10 @@ -//! A movable, persistent set of shortcuts to the shared command registry. -use egui::{Rect, Sense, vec2}; +//! A movable, persistent set of shortcuts to the shared command registry and core document tools. +use egui::{Color32, CornerRadius, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::Edit; use pdfcraft_engine::commands::{COMMANDS, command}; use serde::{Deserialize, Serialize}; -use crate::{PdfCraftApp, icons, theme::Tokens}; +use crate::{PdfCraftApp, QuickTool, comments, icons, theme, theme::Tokens}; /// Persistent state and UI state for the customizable document hotbar. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -16,6 +17,8 @@ pub(crate) struct Hotbar { pub button_size: f32, pub dock: Option, pub full_side: bool, + #[serde(default)] + pub placed: bool, #[serde(skip)] search: String, #[serde(skip)] @@ -23,15 +26,16 @@ pub(crate) struct Hotbar { } impl Default for Hotbar { - /// Create a default empty horizontal floating hotbar. + /// Create a default vertical floating hotbar positioned in the document margin. fn default() -> Self { Self { tools: Vec::new(), - position: [0.5, 0.0], - vertical: false, + position: [0.0, 0.0], + vertical: true, button_size: 32.0, dock: None, full_side: false, + placed: false, search: String::new(), picker_open: false, } @@ -51,6 +55,24 @@ impl Hotbar { for coordinate in &mut self.position { *coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 }; } + if self.dock.is_some() || self.position != [0.0, 0.0] { + self.placed = true; + } + } + + /// Adopt preferences saved before the hotbar absorbed the quick-action bar (#5890), which have + /// no `placed` key. A bar left at that release's default (floating, horizontal, top centre) + /// moves to the new default in the left margin; any other spot is one the user chose, so it + /// is kept, including the top-left corner. + pub fn adopt_legacy(&mut self) { + if self.dock.is_none() && !self.vertical && self.position == [0.5, 0.0] { + self.position = [0.0, 0.0]; + self.vertical = true; + self.full_side = false; + self.placed = false; + } else { + self.placed = true; + } } /// Parse and validate a comma-separated list of command identifiers for the hotbar. @@ -68,6 +90,7 @@ impl Hotbar { pub fn place(&mut self, side: &str) -> Result<(), String> { if side == "float" { self.dock = None; + self.placed = true; return Ok(()); } let (position, vertical) = match side { @@ -80,12 +103,13 @@ impl Hotbar { self.dock = Some(side.to_owned()); self.position = position; self.vertical = vertical; + self.placed = true; Ok(()) } } /// Render the hotbar settings gear button and open the tool picker when clicked. -fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { +fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) -> bool { let settings = ui .push_id("hotbar-settings", |ui| { icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools")) @@ -93,6 +117,25 @@ fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) { .inner; if settings.clicked() { *picker_open = true; + true + } else { + false + } +} + +/// Determine where flyout popup menus should open from a toolbar button based on docking and layout. +fn popup_align_for(dock: Option<&str>, vertical: bool) -> egui::RectAlign { + match dock { + Some("right") => egui::RectAlign::LEFT_START, + Some("bottom") => egui::RectAlign::TOP_START, + Some("top") => egui::RectAlign::BOTTOM_START, + _ => { + if vertical { + egui::RectAlign::RIGHT_START + } else { + egui::RectAlign::BOTTOM_START + } + } } } @@ -103,7 +146,7 @@ fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) -> tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search) } -/// Render the configured tool shortcut buttons and their removal context menus. +/// Render the configured custom tool shortcut buttons and their removal context menus. fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option) { for (index, id) in tools.iter().enumerate() { let Some(spec) = command(id) else { continue }; @@ -121,30 +164,279 @@ fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f3 } } -/// Draw the floating or side-docked hotbar and its tool picker window inside `area`. -pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { +/// Render core quick tools: Select, Hand, Comment groups, Edit text, and Fill & Sign. +fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_align: egui::RectAlign, t: &Tokens) { + if icons::button(ui, "mouse-pointer-2", cell, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() { + app.quick_tool = QuickTool::Select; + } + if icons::button(ui, "hand", cell, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() { + app.quick_tool = QuickTool::Hand; + } + // Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a + // group selects its last-used tool; clicking it again opens the flyout. + for g in 0..comments::GROUPS.len() { + let current = app.comment_prefs.group_tool[g]; + let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g); + let resp = icons::button(ui, current.icon(), cell, active, tl!(current.label())); + if comments::GROUPS[g].len() == 1 { + if resp.clicked() { + app.execute(current.command()); + } + continue; + } + // A small corner triangle marks the flyout. + let r = resp.rect; + let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE)); + let open = (resp.clicked() && active) || resp.secondary_clicked(); + if resp.clicked() && !active { + app.execute(current.command()); + } + egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| { + for tool in comments::GROUPS[g] { + let on = app.quick_tool == QuickTool::Comment(*tool); + let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); + let press = theme::Press::track(ui, &click); + press.wash(ui, row, 4, on); + let body = row.translate(press.offset()); + icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); + let reserve = if on { 28.0 } else { 8.0 }; + crate::widgets::paint_left( + ui, + body.left_center() + vec2(34.0, 0.0), + tl!(tool.label()), + theme::regular(13.0), + t.text, + (row.width() - 34.0 - reserve).max(0.0), + ); + if on { + icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent); + } + let click = theme::hand(click); + let info = tl!(tool.label()).to_string(); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); + if click.clicked() { + app.execute(tool.command()); + ui.close(); + } + } + }); + } + // Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar) + // or the tool is in use. + if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText { + let on = app.quick_tool == QuickTool::EditText; + if icons::button(ui, "text-cursor-input", cell, on, tl!("Edit text & images")).clicked() { + if on { + app.put_down_edit_text(); + } else { + app.quick_tool = QuickTool::EditText; + } + } + } + // Fill & Sign ▸ (text, marks, date, signature). + let current_fill = match app.quick_tool { + QuickTool::Fill(f) => Some(f), + _ => None, + }; + let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text); + let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign")); + let r = resp.rect; + let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)]; + ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE)); + if resp.clicked() && current_fill.is_none() { + app.execute(shown.command()); + } + let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked(); + egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| { + for tool in crate::fill_sign::FILL_TOOLS { + if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) { + continue; + } + let on = current_fill == Some(tool); + let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click()); + let press = theme::Press::track(ui, &click); + press.wash(ui, row, 4, on); + let body = row.translate(press.offset()); + icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text); + let reserve = if on { 28.0 } else { 8.0 }; + crate::widgets::paint_left( + ui, + body.left_center() + vec2(34.0, 0.0), + tl!(tool.label()), + theme::regular(13.0), + t.text, + (row.width() - 34.0 - reserve).max(0.0), + ); + if on { + icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent); + } + let click = theme::hand(click); + let info = tl!(tool.label()).to_string(); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); + if click.clicked() { + app.execute(tool.command()); + ui.close(); + } + } + ui.separator(); + if let Some(command) = crate::fill_sign::signature_entries(ui, app, t) { + app.execute(command); + ui.close(); + } + }); +} + +/// Extra controls rendered below/beside the buttons when a comment or fill tool is active. +fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) { + let current_fill = match app.quick_tool { + QuickTool::Fill(f) => Some(f), + _ => None, + }; + if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { + if vertical { + ui.label(tl!("Font size:")); + } + let size = ui.add( + egui::DragValue::new(&mut app.fill_text_size) + .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE) + .speed(0.25) + .suffix(" pt"), + ); + if ((size.changed() && !size.dragged()) || size.drag_stopped()) + && let Some((index, id)) = app.active_ids() + && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) + && app.session.get(id).is_some_and(|doc| { + doc.allows_annotation() + && doc + .info + .annotations + .iter() + .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked) + }) + { + app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); + } + ui.menu_button(tl!("Colour"), |ui| { + if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { + app.fill_text_color = color; + ui.close(); + } + }); + } + if let QuickTool::Comment(tool) = app.quick_tool { + let (r, _) = if vertical { + ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover()) + } else { + ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover()) + }; + if vertical { + ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); + } else { + ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider)); + } + if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs) + && let Some((index, id)) = app.active_ids() + && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected) + && app.session.get(id).is_some_and(|doc| { + doc.allows_annotation() + && doc + .info + .annotations + .iter() + .any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool)) + }) + { + app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None }); + } + } +} + +/// Render a modern iOS-style pill toggle switch. +fn toggle_switch(ui: &mut egui::Ui, on: &mut bool, label: &str, t: &Tokens) -> egui::Response { + let width = 34.0; + let height = 18.0; + let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), Sense::click()); + if response.clicked() { + *on = !*on; + response.mark_changed(); + } + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Switch, ui.is_enabled(), *on, label)); + + if ui.is_rect_visible(rect) { + let radius = height / 2.0; + let bg_color = if *on { t.accent } else { t.border }; + ui.painter().rect(rect, CornerRadius::same(radius as u8), bg_color, Stroke::NONE, egui::StrokeKind::Inside); + let circle_radius = radius - 2.5; + let circle_x = if *on { rect.max.x - radius } else { rect.min.x + radius }; + ui.painter().circle_filled(egui::pos2(circle_x, rect.center().y), circle_radius, Color32::WHITE); + } + response +} + +/// Render the dock placement choice: [ Float | Left | Right | Top | Bottom ], scrolling sideways +/// when the picker is narrower than the row. +fn segmented_dock_picker(ui: &mut egui::Ui, bar: &mut Hotbar, t: &Tokens) { + let options = [ + (None, tl!("Float")), + (Some("left"), tl!("Left")), + (Some("right"), tl!("Right")), + (Some("top"), tl!("Top")), + (Some("bottom"), tl!("Bottom")), + ]; + let mut side = options.iter().map(|(side, _)| *side).find(|side| *side == bar.dock.as_deref()).flatten(); + let changed = egui::ScrollArea::horizontal() + .id_salt("hotbar-dock") + .auto_shrink([false, true]) + .show(ui, |ui| crate::dialogs::preferences::segmented_pill_choice(ui, t, &mut side, &options)) + .inner; + if changed { + // `place` accepts every side listed above. + let _ = bar.place(side.unwrap_or("float")); + } +} + +/// Draw the unified floating or side-docked toolbar, combining quick actions, core tools, and custom shortcuts. +pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); let mut bar = std::mem::take(&mut app.hotbar); - let t = Tokens::get(ctx); - let id = egui::Id::unique("custom-hotbar"); + bar.normalize(); + let t = Tokens::get(&ctx); + let id = egui::Id::unique("quick-bar"); let full_side = bar.full_side && bar.dock.is_some(); let bounds = if full_side { area } else { area.shrink(10.0) }; if let Some(side) = bar.dock.clone() { let _ = bar.place(&side); } let cell = bar.button_size; - let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(90.0, 44.0)); + let popup_align = popup_align_for(bar.dock.as_deref(), bar.vertical); + let mut size = ctx.data_mut(|data| data.get_temp::(id)).unwrap_or(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 220.0)); if full_side { size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) }; } let travel = (bounds.size() - size).max(vec2(0.0, 0.0)); - let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]); + let default_x = crate::canvas::bars::quick_bar_offset(page_left.map_or(0.0, |left| left - area.left()), size.x); + let pos = if full_side { + bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]) + } else if bar.dock.is_none() && !bar.placed { + area.left_top() + vec2(default_x, crate::canvas::bars::QUICK_BAR_EDGE) + } else { + bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]) + }; let mut run = None; + let was_picker_open = bar.picker_open; let mut picker_open = bar.picker_open; - let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| { + let mut settings_clicked = false; + let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(&ctx, |ui| { let frame = if full_side { egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6) } else { - egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6) + egui::Frame::new() + .fill(t.card) + .stroke(egui::Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(12)) + .shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) }) + .inner_margin(6) }; let frame_resp = frame.show(ui, |ui| { if full_side { @@ -160,23 +452,33 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { if full_side { if bar.vertical { ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { - settings_button(ui, cell, &mut picker_open); + settings_clicked |= settings_button(ui, cell, &mut picker_open); ui.add_space(4.0); let remaining = ui.available_height().max(cell); - egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(remaining).show(ui, |ui| { ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { - tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + core_tool_buttons(ui, app, cell, popup_align, &t); + active_tool_controls(ui, app, &t, true); + if !bar.tools.is_empty() { + ui.separator(); + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + } }); }); }); } else { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - settings_button(ui, cell, &mut picker_open); + settings_clicked |= settings_button(ui, cell, &mut picker_open); ui.add_space(4.0); let remaining = ui.available_width().max(cell); egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| { ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + core_tool_buttons(ui, app, cell, popup_align, &t); + active_tool_controls(ui, app, &t, false); + if !bar.tools.is_empty() { + ui.separator(); + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + } }); }); }); @@ -198,21 +500,44 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label)); if grip.dragged() && bar.dock.is_none() { let delta = grip.drag_delta(); + if !bar.placed { + let cur_x = (pos.x - bounds.min.x).max(0.0); + let cur_y = (pos.y - bounds.min.y).max(0.0); + bar.position[0] = (cur_x / travel.x.max(1.0)).clamp(0.0, 1.0); + bar.position[1] = (cur_y / travel.y.max(1.0)).clamp(0.0, 1.0); + } bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0); bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0); + bar.placed = true; } - let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) }; - let scroll = if bar.vertical { - egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max) - } else { - egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max) - }; - scroll.auto_shrink([true, true]).show(ui, |ui| { - ui.with_layout(layout, |ui| { - tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + if bar.vertical { + let fixed_overhead = grip_size.y + cell + 24.0; + let max_tools_length = (bounds.height() - fixed_overhead).max(cell); + egui::ScrollArea::vertical().max_height(max_tools_length).show(ui, |ui| { + ui.with_layout(layout, |ui| { + core_tool_buttons(ui, app, cell, popup_align, &t); + active_tool_controls(ui, app, &t, true); + if !bar.tools.is_empty() { + ui.separator(); + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + } + }); }); - }); - settings_button(ui, cell, &mut picker_open); + settings_clicked |= settings_button(ui, cell, &mut picker_open); + } else { + core_tool_buttons(ui, app, cell, popup_align, &t); + active_tool_controls(ui, app, &t, false); + if !bar.tools.is_empty() { + ui.separator(); + let scroll_max = (bounds.width() - cell - 50.0).max(cell); + egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max).show(ui, |ui| { + ui.with_layout(layout, |ui| { + tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove); + }); + }); + } + settings_clicked |= settings_button(ui, cell, &mut picker_open); + } }); } if let Some(index) = remove { @@ -241,50 +566,132 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { }); let picker_id = egui::Id::unique("hotbar-picker"); // A remembered collapsed state can survive disabling the collapse affordance. - let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true); + let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(&ctx, picker_id.with("collapsing"), true); expanded.set_open(true); - expanded.store(ctx); + expanded.store(&ctx); let screen = ctx.content_rect().shrink(12.0); - let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 }; - let picker_pos = egui::pos2( - picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())), - pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())), - ); - egui::Window::new(tl!("Hotbar tools")) + let margin_x = 14.0; + let card_margin_total = margin_x * 2.0; + let card_width = 280.0f32.min((screen.width() - card_margin_total).max(180.0)); + let outer_width = card_width + card_margin_total; + let bar_rect = shown.response.rect; + let picker_x = + if bar_rect.right() + outer_width + 10.0 < screen.right() { bar_rect.right() + 14.0 } else { bar_rect.left() - outer_width - 14.0 }; + let picker_y = if bar.dock.as_deref() == Some("top") { + (bar_rect.bottom() + 10.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top())) + } else if bar.dock.as_deref() == Some("bottom") { + (bar_rect.top() - 500.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top())) + } else { + bar_rect.top().clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top())) + }; + let picker_pos = egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y); + let mut close_dialog = false; + let picker_resp = egui::Window::new(tl!("Hotbar tools")) .id(picker_id) .order(egui::Order::Foreground) .open(&mut picker_open) .collapsible(false) .resizable(false) + .title_bar(false) .fixed_pos(picker_pos) - .max_height((screen.height() - 24.0).max(180.0)) - .vscroll(true) - .show(ctx, |ui| { - ui.set_min_width(260.0); - ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px")); - ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout"))); - if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() { - let _ = bar.place("float"); - } - ui.label(tl!("Stick to a side:")); + .frame( + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(10)) + .shadow(egui::Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(if t.dark() { 120 } else { 35 }) }) + .inner_margin(egui::Margin::symmetric(14, 12)), + ) + .show(&ctx, |ui| { + ui.set_width(card_width); + + // Title bar: "Hotbar Tools" on the left, "X" close button on the right ui.horizontal(|ui| { - for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] { - if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() { - let _ = bar.place(side); + ui.label(egui::RichText::new(tl!("Hotbar tools")).size(14.0).strong().color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "x", 16.0, false, tl!("Close menu")).clicked() { + close_dialog = true; } - } + }); }); + + ui.add_space(10.0); + + // Section 1: Layout & Sizing + ui.label(egui::RichText::new(tl!("Layout & Sizing")).size(13.0).strong().color(t.text)); + ui.add_space(6.0); + + // Toolbar size slider row + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Toolbar size")).size(12.5).color(t.text)); + let slider_width = (ui.available_width() - 55.0).clamp(50.0, 140.0); + ui.spacing_mut().slider_width = slider_width; + ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(format!("{:.0} px", bar.button_size)).size(12.0).color(t.text_muted)); + }); + }); + + ui.add_space(8.0); + + // Vertical layout toggle switch row + ui.horizontal(|ui| { + ui.add_enabled_ui(bar.dock.is_none(), |ui| { + let label = tl!("Vertical layout"); + toggle_switch(ui, &mut bar.vertical, label, &t); + ui.add_space(8.0); + ui.label(egui::RichText::new(label).size(12.5).color(t.text)); + }); + }); + + ui.add_space(10.0); + + // Stick to a side section with segmented control + ui.label(egui::RichText::new(tl!("Stick to a side:")).size(12.5).color(t.text)); + ui.add_space(6.0); + segmented_dock_picker(ui, &mut bar, &t); + + ui.add_space(8.0); + + // Full-side strip checkbox let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable."); ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip"))) .on_hover_text(strip_hint) .on_disabled_hover_text(strip_hint); - if ui.button(tl!("Close menu")).clicked() { - ui.close(); - } + + ui.add_space(12.0); ui.separator(); - ui.label(tl!("Add tools to hotbar")); - ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…"))); - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + ui.add_space(10.0); + + // Section 2: Add Tools to Hotbar + ui.label(egui::RichText::new(tl!("Add Tools to Hotbar")).size(13.0).strong().color(t.text)); + ui.add_space(8.0); + + // Search bar pill + let search_frame = egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)); + search_frame.show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + ui.add(icons::image("search", 14.0, t.text_faint)); + ui.add( + egui::TextEdit::singleline(&mut bar.search) + .hint_text(tl!("Search all tools…")) + .frame(egui::Frame::NONE) + .desired_width(ui.available_width()), + ); + }); + }); + + ui.add_space(8.0); + + // Scrollable tools list with checkboxes + let max_list_height = (screen.height() - 340.0).clamp(120.0, 220.0); + egui::ScrollArea::vertical().max_height(max_list_height).auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 5.0; for spec in COMMANDS { if !matches_search(spec, &bar.search) { continue; @@ -299,9 +706,23 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) { } } }); - ui.separator(); }); - ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size())); + let clicked_outside = was_picker_open + && !settings_clicked + && picker_resp.as_ref().is_some_and(|wr| { + wr.response.clicked_elsewhere() + || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| wr.response.rect.contains(p))) + }); + if clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + close_dialog = true; + } + if close_dialog { + picker_open = false; + } + ctx.data_mut(|data| { + data.insert_temp(id, shown.response.rect.size()); + data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size()); + }); bar.picker_open = picker_open; app.hotbar = bar; if let Some(id) = run { @@ -358,6 +779,24 @@ mod tests { assert_eq!(app.hotbar.position, [0.0, 1.0]); } + #[test] + fn hotbar_restore_moves_legacy_default_into_margin_and_keeps_chosen_spots() { + let mut app = PdfCraftApp::new(); + app.restore(r#"{"hotbar":{"tools":["comment.ink"],"position":[0.5,0.0],"vertical":false,"button_size":40.0,"dock":null}}"#); + assert_eq!(app.hotbar.position, [0.0, 0.0]); + assert!(app.hotbar.vertical); + assert!(!app.hotbar.placed); + assert_eq!(app.hotbar.tools, ["comment.ink"]); + assert_eq!(app.hotbar.button_size, 40.0); + app.restore(r#"{"hotbar":{"position":[0.0,0.0],"vertical":false,"dock":null}}"#); + assert_eq!(app.hotbar.position, [0.0, 0.0]); + assert!(!app.hotbar.vertical); + assert!(app.hotbar.placed, "a legacy bar dragged to the corner stays there"); + app.restore(r#"{"hotbar":{"position":[0.5,0.0],"vertical":false,"dock":null,"placed":true}}"#); + assert_eq!(app.hotbar.position, [0.5, 0.0], "current preferences are not migrated"); + assert!(!app.hotbar.vertical); + } + #[test] fn hotbar_search_matches_translated_and_canonical_labels() { let ink = command("comment.ink").unwrap(); diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index fa2d7f9b1a..8c3dee280c 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -111,7 +111,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, return None; } let c = view.comments.composer.take()?; - let text = c.text.trim_end().to_string(); + let typewriter_edit = matches!(c.kind, ComposerKind::Edit(index) if info.annotations.iter().any(|a| + a.page == page && a.index == index && a.subtype == "FreeText" && a.intent.as_deref() == Some("FreeTextTypeWriter") + )); + let text = if typewriter_edit { c.text } else { c.text.trim_end().to_string() }; match c.kind { ComposerKind::Edit(index) => Some(Edit::SetAnnotationContents { page, index, text }), ComposerKind::Note => { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 19a165c152..681564c146 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -23,7 +23,8 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ - ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, + ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, handle_hit as picture_handle_hit, image_context_menu, image_input, + quad_contains, user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 281f7de80e..600f3d8801 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -246,6 +246,29 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) } +fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { + let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); + if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) } +} + +/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove +/// Crop pills are widgets of their own, so they take their clicks before the canvas does. +pub(crate) fn crop_controls_contain( + image: &pdfcraft_engine::PageImage, + mode: &CropMode, + xf: &PageXform, + info: &DocInfo, + page: usize, + p: Pos2, +) -> bool { + let b = crop_bounds(image, xf, info, page); + let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b); + if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { + return false; + } + k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0) +} + /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` /// (the pointer and keys are the crop's while it lasts). #[allow(clippy::cognitive_complexity)] @@ -254,8 +277,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; - let full_b = xf.user_rect(info, page, image.rect.map(|v| v as f32)); - let b = if full_b.is_positive() { full_b } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; + let b = crop_bounds(image, xf, info, page); let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); let k = keep_screen(mode.keep); if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..597e139c9e 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -490,7 +490,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m FillAction::Edit(Box::new(typed_with_color( tb.page, tb.at, - tb.text.trim(), + &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, @@ -555,14 +555,20 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let Some(t) = view.fill_text.as_mut() else { return }; // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); + let font = egui::FontId::proportional((t.size as f32 * zoom).max(8.0)); + let natural = ui.fonts_mut(|f| f.layout_no_wrap(t.text.clone(), font.clone(), Color32::BLACK).size().x); + let margin = egui::Margin::symmetric(4, 2); + // Include the field padding and a pixel for layout rounding in the measured width. + let width = (natural + margin.sum().x + 1.0).max(8.0 * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); let r = ui .add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + egui::TextEdit::multiline(&mut t.text) + .margin(margin) + .desired_rows(1) + .font(font) .desired_width(width) .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) .text_color(crate::comments::color32(t.color)) @@ -587,7 +593,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, if commit { let tb = view.fill_text.take()?; if !tb.text.trim().is_empty() { - return Some(typed_with_color(tb.page, tb.at, tb.text.trim(), tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); + return Some(typed_with_color(tb.page, tb.at, &tb.text, tb.size, crate::comments::page_rotation(info, tb.page), author, tb.color)); } } None diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 6805732422..bd70d802ef 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2925,7 +2925,7 @@ fn moved_fill_and_sign_text_is_drawn_where_it_goes_before_release() { click_pt(&mut h, (40.0, 150.0)); h.event(egui::Event::Text("WWWWWW".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + click(&mut h, pos2(350.0, 850.0)); // Outside the field: Enter now inserts a line break. h.run_steps(3); let a = comments(&h).into_iter().find(|a| a.contents.as_deref() == Some("WWWWWW")).expect("the typed text"); assert!(a.fill_sign && a.subtype == "FreeText", "{a:?}"); @@ -3199,6 +3199,29 @@ fn hotbar_vertical_size_and_docking() { assert_ne!(saved["hotbar"]["position"], moved["hotbar"]["position"]); } +#[test] +fn hotbar_settings_layout_and_side_controls_are_reachable_by_label() { + let mut h = harness(|_| {}); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + fn vertical(h: &Harness<'static, PdfCraftApp>) -> serde_json::Value { + serde_json::from_str::(&h.state().persist()).unwrap()["hotbar"]["vertical"].clone() + } + let before = vertical(&h); + h.get_by_role_and_label(Role::Switch, "Vertical layout").click(); + h.run_steps(3); + assert_ne!(vertical(&h), before, "the layout switch toggles by its label"); + h.get_by_role_and_label(Role::RadioButton, "Right").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert_eq!(saved["hotbar"]["dock"], "right"); + assert_eq!(saved["hotbar"]["vertical"], true, "docking at the side lays the bar out vertically"); + h.get_by_role_and_label(Role::RadioButton, "Float").click(); + h.run_steps(3); + let saved: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap(); + assert!(saved["hotbar"]["dock"].is_null()); +} + #[test] fn hotbar_settings_reopen_even_with_remembered_collapsed_state() { let mut h = harness(|_| {}); @@ -3246,5 +3269,195 @@ fn hotbar_full_side_strip_spans_each_edge_and_returns_to_float() { h.state_mut().set_option("hotbar-side", "float").unwrap(); h.run_steps(5); let size = h.ctx.data_mut(|d| d.get_temp::(egui::Id::unique("custom-hotbar"))).unwrap(); - assert!(size.x < 150.0 && size.y < 150.0, "floating toolbar is compact: {size:?}"); + let (length, thickness) = if size.y > size.x { (size.y, size.x) } else { (size.x, size.y) }; + assert!(thickness <= 64.0 && length < 550.0, "floating toolbar is compact: {size:?}"); +} + +#[test] +fn hotbar_docked_left_does_not_override_quick_tools() { + let mut h = harness(|app| { + app.set_option("hotbar-side", "left").unwrap(); + }); + h.run_steps(5); + // Controls in the docked bar share one layout, so none may overlap (#5890). + let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"]; + let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect(); + for (i, a) in rects.iter().enumerate() { + for (j, b) in rects.iter().enumerate().skip(i + 1) { + let overlap = a.min.x < b.max.x && b.min.x < a.max.x && a.min.y < b.max.y && b.min.y < a.max.y; + assert!(!overlap, "{} overlaps {}: {a:?} / {b:?}", labels[i], labels[j]); + } + } +} + +#[test] +fn hotbar_docked_in_compact_window_keeps_settings_button_visible() { + for side in ["left", "right"] { + let mut h = Harness::builder().with_size(egui::vec2(800.0, 480.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("hotbar-side", side).unwrap(); + app.set_option("hotbar-size", "48").unwrap(); + app + }); + h.run_steps(5); + let settings_rect = h.get_by_label("Hotbar settings").rect(); + assert!(settings_rect.max.y <= 480.0, "{side} hotbar settings button must stay within window height: {settings_rect:?}"); + } +} + +#[test] +fn hotbar_settings_menu_closes_on_click_outside() { + let mut h = harness(|app| { + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + }); + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some()); + + // Clicking outside on the canvas dismisses the settings menu + h.event(egui::Event::PointerButton { + pos: egui::pos2(600.0, 300.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }); + h.event(egui::Event::PointerButton { + pos: egui::pos2(600.0, 300.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_none(), "clicking outside closes the hotbar settings menu"); + + // Reopening and clicking another tool outside dismisses the menu and activates that tool + h.get_by_label("Hotbar settings").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_some()); + h.get_by_label("Hand (H)").click(); + h.run_steps(3); + assert!(h.query_by_label("Close menu").is_none()); + assert_eq!(h.state().quick_tool, QuickTool::Hand); +} + +#[test] +fn hotbar_picker_in_narrow_viewport_fits_within_screen_width() { + let mut h = Harness::builder().with_size(egui::vec2(270.0, 480.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app + }); + h.get_by_label("Hotbar settings").click(); + h.run_steps(5); + let close_btn = h.get_by_label("Close menu"); + assert!(close_btn.rect().max.x <= 270.0, "Close menu button must stay within viewport width: {:?}", close_btn.rect()); +} + +#[test] +fn hotbar_floating_picker_does_not_overlap_rendered_bar() { + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 600.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens"); + app + }); + let settings_btn = h.get_by_label("Hotbar settings"); + let settings_rect = settings_btn.rect(); + settings_btn.click(); + h.run_steps(5); + + let picker_close = h.get_by_label("Close menu"); + let picker_rect = picker_close.rect(); + assert!( + picker_rect.min.x >= settings_rect.max.x || picker_rect.max.x <= settings_rect.min.x, + "picker ({:?}) must not overlap the settings button ({:?}) on the floating bar", + picker_rect, + settings_rect + ); +} + +fn edit_composer_preserves_only_typewriter_whitespace(kind: u8) { + let shots = std::env::var_os("PDFCRAFT_COMPOSER_SHOTS").is_some(); + let _gpu = shots.then(crate::gpu); + use pdfcraft_engine::{Edit, NewAnnotation, NoteIcon, Shape, Style}; + let mut h = harness(move |app| { + app.set_option("layout", "single").unwrap(); + app.set_option("panel", "comments").unwrap(); + app.quick_tool = QuickTool::Select; + // Both pages use index 0, with opposite intents. The lookup must use page AND index. + for page in 0..2 { + let typewriter = if page == 0 { kind != 0 } else { kind == 0 }; + let shape = if typewriter { + Shape::Typewriter { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + } else if kind == 2 { + Shape::Note { at: [80.0, 80.0], icon: NoteIcon::Comment } + } else { + Shape::TextBox { rect: [40.0, 40.0, 180.0, 90.0], font_size: 10.0 } + }; + let style = Style::default_for(&shape); + assert!(app.apply_edit(Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: "Original".into(), author: "Tester".into() }))); + } + }); + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + support::settle(&mut h); + let point = { + let s = h.state(); + let v = &s.views[0]; + let doc = s.session.get(v.id).unwrap(); + let a = doc.info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + let page = v.page_screen_rect(1).expect("the second page is visible"); + let info = &doc.info.pages[1]; + let p = info.user_to_view((a.rect[0] + a.rect[2]) / 2.0, (a.rect[1] + a.rect[3]) / 2.0); + page.min + egui::vec2(p[0], p[1]) * (page.width() / info.width) + }; + support::input::double_click(&mut h, point); + h.run_steps(3); + let c = h.state().views[0].comments.composer.as_ref().expect("double-click opens the edit composer"); + assert_eq!(c.page, 1); + assert_eq!(c.kind, pdfcraft_ui_egui::comments::ComposerKind::Edit(0)); + h.input_mut().events.push(egui::Event::Text(" edited\n\n".into())); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.composer.as_ref().unwrap().text, "Original edited\n\n"); + if shots { + h.render().unwrap().save(format!("/tmp/5954-composer-{kind}.png")).unwrap(); + } + h.get_all_by_label("Post").last().unwrap().click(); + h.run_steps(3); + assert!(h.state().views[0].comments.composer.is_none()); + let id = h.state().views[0].id; + let bytes = h.state().session.save_bytes(id).unwrap(); + // Check the raw saved string: ordinary-comment reader trimming must not hide a bad post. + let cos = pdfcraft_cos::Document::open(bytes.clone()).unwrap(); + let pages = pdfcraft_model::pages(&cos); + let annots = cos.resolve(pages[1].dict.get(b"Annots").unwrap()); + let annot = cos.resolve(&annots.as_array().unwrap()[0]); + let contents = cos.resolve(annot.as_dict().unwrap().get(b"Contents").unwrap()); + let expected = if kind == 0 { "Original edited\n\n" } else { "Original edited" }; + assert_eq!(contents.as_string().unwrap().to_text(), expected, "only a typewriter edit preserves trailing blank lines"); + let mut reopened = pdfcraft_engine::Session::new(); + let again = reopened.open("edited.pdf", None, bytes, None).unwrap(); + let saved = reopened.get(again).unwrap().info.annotations.iter().find(|a| a.page == 1 && a.index == 0).unwrap(); + assert_eq!(saved.contents.as_deref(), Some(expected)); +} + +#[test] +fn typewriter_edit_composer_keeps_trailing_blank_lines() { + edit_composer_preserves_only_typewriter_whitespace(0); +} + +#[test] +fn other_edit_composers_keep_trimming_trailing_whitespace() { + for kind in [1, 2] { + edit_composer_preserves_only_typewriter_whitespace(kind); + } } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 7b245a5a9e..c4231d0f69 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7884,6 +7884,254 @@ fn test_image_context_menu_precedence() { assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger"); } +#[test] +fn test_image_context_menu_in_crop_mode_and_when_moved() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(100, 100, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + // Shrink and center the picture on the page so page margins exist around it + let full = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0); + let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 0, + change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]), + }); + h.run_steps(4); + + 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 c = r.center(); + + // Select the image + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + + // Enter crop mode: + let doc_id = h.state().views[0].id; + let original_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(original_rect)); + h.run_steps(2); + + // Set a smaller crop box that is moved towards top-left + let moved_crop = egui::Rect::from_center_size(c - egui::vec2(5.0, 5.0), egui::vec2(20.0, 20.0)); + set_crop_box(&mut h, moved_crop); + h.run_steps(2); + + // Simulate secondary click on the moved crop box + let click_pos = moved_crop.center(); + h.hover_at(click_pos); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: click_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + // In crop mode and when moved, do not open right click window when clicked on the image (#5835) + assert!(h.query_all_by_label("Rotate Clockwise").next().is_none(), "image context menu must not trigger in crop mode"); + assert!(h.query_all_by_label("Flip horizontal").next().is_none(), "image context menu must not trigger in crop mode"); + assert!(h.query_all_by_label("Rotate view clockwise").next().is_none(), "canvas background menu must not trigger in crop mode"); + assert!(h.query_all_by_label("Add bookmark").next().is_none(), "canvas background menu must not trigger in crop mode"); + assert!(h.query_all_by_label("Take a snapshot").next().is_none(), "canvas background menu must not trigger in crop mode"); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode must remain active after right click on image"); + // Action pills remain visible and interactive + assert!(h.query_by_label("Done").is_some()); + assert!(h.query_by_label("Cancel").is_some()); + + // Right clicking outside the image on the page exits crop mode and opens the canvas background menu + let outside_pos = r.left_top() + egui::vec2(10.0, 10.0); + h.hover_at(outside_pos); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: outside_pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + + assert!(h.state().views[0].image_selection.is_none(), "clicking outside clears image selection"); + assert!(h.query_all_by_label("Rotate view clockwise").next().is_some(), "canvas background menu triggers outside image"); +} + +fn crop_secondary_click(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) { + assert!(h.state().views[0].viewport_rect().contains(p), "test point must be visible: {p:?}"); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn assert_crop_context_preserved(h: &Harness<'static, PdfCraftApp>) { + let selection = h.state().views[0].image_selection.as_ref().expect("crop remains selected"); + assert_eq!(selection.index, 0, "an overlapping image must not steal the crop"); + assert!(selection.crop.is_some(), "crop mode remains active"); + for label in ["Flip horizontal", "Rotate view clockwise", "Take a snapshot"] { + assert!(h.query_all_by_label(label).next().is_none(), "unexpected menu: {label}"); + } + assert!(h.query_by_label("Done").is_some()); + assert!(h.query_by_label("Cancel").is_some()); +} + +#[test] +fn test_image_context_menu_crop_overlapping_image_and_handle() { + for rotation in [0, 90, 180, 270] { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("overlap.pdf", None, two_image_fixture()).unwrap(); + app.views[0].rotation = rotation; + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect; + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 1, change: pdfcraft_engine::ImageEdit::Move(rect) }); + let images = h.state().session.get(id).unwrap().page_images(0); + assert_eq!(images.len(), 2); + assert_eq!(images[0].rect, images[1].rect, "fixture images must overlap"); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let image_rect = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0); + crop_secondary_click(&mut h, image_rect.center()); + assert_crop_context_preserved(&h); + // This point is outside both images, but inside the left handle's grip. + crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(5.0, 0.0)); + assert_crop_context_preserved(&h); + crop_secondary_click(&mut h, image_rect.left_center() - egui::vec2(20.0, 0.0)); + assert!(h.state().views[0].image_selection.is_none()); + assert!(h.query_by_label("Rotate view clockwise").is_some()); + } +} + +#[test] +fn test_image_context_menu_crop_page_edge_pills() { + for rotation in [0, 90, 180, 270] { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("edge.pdf", None, two_image_fixture()).unwrap(); + app.views[0].rotation = rotation; + app.views[0].fit = pdfcraft_ui_egui::canvas::Fit::Page; + app + }); + h.run_steps(4); + // A small image at a page corner exercises horizontal clamping and above placement. + let rect = [1.0, 1.0, 21.0, 21.0]; + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Move(rect) }); + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 1, + change: pdfcraft_engine::ImageEdit::Move([100.0, 150.0, 120.0, 170.0]), + }); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new([2.0, 2.0, 20.0, 20.0])); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + for label in ["Done", "Cancel", "Remove Crop"] { + let p = h.get_by_label(label).rect().center(); + crop_secondary_click(&mut h, p); + assert_crop_context_preserved(&h); + } + // Beyond egui's 5-point interaction radius, empty space beside the rightmost pill is canvas. + let beside = h.get_by_label("Done").rect().right_center() + egui::vec2(8.0, 0.0); + let view = &h.state().views[0]; + let doc = h.state().session.get(view.id).unwrap(); + let xf = view.page_xform(0).unwrap(); + assert!( + doc.page_images(0).iter().all(|img| !pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, &doc.info, 0).contains(beside)), + "background test point must miss both images" + ); + crop_secondary_click(&mut h, beside); + assert!( + h.state().views[0].image_selection.is_none(), + "empty pill-adjacent space, rotation={rotation}, point={beside:?}, viewport={:?}, selection={:?}", + h.state().views[0].viewport_rect(), + h.state().views[0].image_selection + ); + assert!(h.query_by_label("Rotate view clockwise").is_some()); + } +} + +#[test] +fn test_rotated_image_crop_secondary_click_in_outer_bounding_box_clears_selection() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 80, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let full = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0); + let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 0, + change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]), + }); + h.run_steps(4); + // Rotate the image by 45° + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) }); + h.run_steps(4); + + 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 c = r.center(); + + // Select the image and put it into crop mode + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + + let doc_id = h.state().views[0].id; + let img = &h.state().session.get(doc_id).unwrap().page_images(0)[0]; + let (cx, cy) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0); + let shrunk_crop = [cx - 5.0, cy - 5.0, cx + 5.0, cy + 5.0]; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop = Some(pdfcraft_ui_egui::CropMode::new(shrunk_crop)); + h.run_steps(2); + + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc_info = &h.state().session.get(doc_id).unwrap().info; + let b = pdfcraft_ui_egui::canvas::image_screen_rect(img, &xf, doc_info, 0); + + // The top-left corner of the axis-aligned bounding box `b` is outside the 45°-rotated diamond quad + let corner = b.left_top() + egui::vec2(2.0, 2.0); + assert!(b.contains(corner), "corner point is inside screen bounding box"); + + // Right-clicking at the corner must clear the crop selection and show the canvas context menu (#5900 review) + crop_secondary_click(&mut h, corner); + assert!(h.state().views[0].image_selection.is_none(), "clicking in outer bounding box of rotated image clears crop selection"); + assert!(h.query_by_label("Rotate view clockwise").is_some()); +} + #[test] fn crop_action_pills_interactive_and_apply_cancel_reset() { let mut png = Vec::new(); diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index ce21bd9f74..858e082137 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -130,7 +130,7 @@ fn text_marks_and_date() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Ada Lovelace".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(4); let v = items(&h); assert_eq!(v.len(), 4, "{v:?}"); @@ -139,9 +139,9 @@ fn text_marks_and_date() { assert!(v.iter().any(|(t, _)| t == "Stamp")); } -/// The open type box, found as the focused text input. +/// The open type box, found by its accessible name for either input role. fn type_box_rect(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { - h.query_all_by_role(egui::accesskit::Role::TextInput).find(|n| n.is_focused()).expect("an open type box").rect() + h.get_by_label("Text to place").rect() } #[test] @@ -149,13 +149,13 @@ fn clicking_elsewhere_with_add_text_keeps_the_typed_text() { let mut h = harness(); h.state_mut().execute("sign.fill.text"); click(&mut h, 50.0, 250.0); - h.event(egui::Event::Text("Ada Lovelace".into())); + h.event(egui::Event::Text("\nAda\nLovelace\n".into())); h.run_steps(1); // Still on Add text: the next click starts a new box, and must keep the first one. click(&mut h, 50.0, 150.0); h.run_steps(2); let v = items(&h); - assert_eq!(v, vec![("FreeText".to_string(), Some("Ada Lovelace".to_string()))]); + assert_eq!(v, vec![("FreeText".to_string(), Some("\nAda\nLovelace\n".to_string()))]); assert!(h.state().views[0].fill_text.as_ref().is_some_and(|t| t.text.is_empty()), "a new, empty box is open"); } @@ -170,7 +170,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() { assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0)); h.event(egui::Event::Text("Larger text".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let annotation = h.state().session.get(h.state().views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some("Larger text")).unwrap(); @@ -192,7 +192,7 @@ fn fill_text_size_resizes_the_selected_text() { click(&mut h, 50.0, 250.0); h.event(egui::Event::Text("Resize me".into())); h.run_steps(1); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let height = |h: &Harness<'static, PdfCraftApp>| { let s = h.state(); @@ -297,7 +297,7 @@ fn fill_text_colour_survives_preview_commit_settings_and_save() { assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().color, color, "the editor uses the selected colour"); h.event(egui::Event::Text("Blue form text".into())); h.run_steps(2); - h.key_press(egui::Key::Enter); + commit_typed_text(&mut h); h.run_steps(3); let id = h.state().views[0].id; let expected = color.map(|v| v as f32); @@ -1812,3 +1812,79 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { h.run_steps(3); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); } + +fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) { + let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box. + h.hover_at(p); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +fn fill_input(h: &mut Harness<'static, PdfCraftApp>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + h.run_steps(2); +} + +fn fill_key(h: &mut Harness<'static, PdfCraftApp>, key: egui::Key) { + fill_input( + h, + [true, false].map(|pressed| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: egui::Modifiers::NONE }), + ); +} + +#[test] +fn fill_sign_enter_adds_lines_outside_click_commits_and_escape_cancels() { + let shots = std::env::var_os("PDFCRAFT_FILL_SIGN_SHOTS").is_some(); + let _gpu = shots.then(gpu); + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.text")); + click(&mut h, 50.0, 300.0); + let initial_height = type_box_rect(&h).height(); + fill_key(&mut h, egui::Key::Enter); + assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.text.as_str()), Some("\n"), "Enter inserts a newline and keeps the editor open"); + fill_input(&mut h, [egui::Event::Text("Ada".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Enter); + fill_input(&mut h, [egui::Event::Text("Lovelace".into())]); + fill_key(&mut h, egui::Key::Enter); + let text = "\nAda\n\nLovelace\n"; + assert_eq!(h.state().views[0].fill_text.as_ref().unwrap().text, text); + assert!(items(&h).is_empty(), "Enter never places an annotation"); + let galley = h + .output() + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(&t.galley), + _ => None, + }) + .unwrap(); + assert_eq!(galley.rows.len(), 5, "short typed lines fit without accidental soft wraps"); + assert!(type_box_rect(&h).height() > initial_height * 3.0, "the editor grows with its lines"); + if shots { + h.render().unwrap().save("/tmp/5850-multiline.png").unwrap(); + } + commit_typed_text(&mut h); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(items(&h), [("FreeText".to_owned(), Some(text.to_owned()))]); + let id = h.state().views[0].id; + let saved = h.state().session.save_bytes(id).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let reopened = session.open("again.pdf", None, saved.clone(), None).unwrap(); + let annot = &session.get(reopened).unwrap().info.annotations[0]; + assert_eq!(annot.contents.as_deref(), Some(text)); + assert!(annot.rect[3] - annot.rect[1] >= 5.0 * 10.0 * 1.2, "the saved box keeps all five lines"); + click(&mut h, 50.0, 180.0); + fill_input(&mut h, [egui::Event::Text("Cancel me".into())]); + fill_key(&mut h, egui::Key::Enter); + fill_key(&mut h, egui::Key::Escape); + assert!(h.state().views[0].fill_text.is_none()); + assert_eq!(h.state().session.save_bytes(id).unwrap(), saved, "Escape cancels without writing"); +} diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 9ca6a7f3cd..76409dc697 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -746,10 +746,12 @@ pub fn parse(xml: &str) -> Result<(Template, Vec), XfaError> { && n.tag_name().name() == "template" && (n.tag_name().namespace().is_some_and(|ns| ns.contains("xfa-template")) || children(*n, "subform").next().is_some()) }; + // The template is a packet of the XDP itself, a child of its root: one nested deeper is + // data (an imported dataset, say), which must not supply the form's scripts. let template = if is_template(&root) { root } else { - root.descendants().find(is_template).ok_or_else(|| XfaError::Malformed("no