From 695e460bbb93a48a6cf3dcbb6e4fe7ecb1ad6593 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:42:18 -0400 Subject: [PATCH] M7: Edit text works on paragraphs: detection, in-place editing with rewrap to the box, original spacing, colour and font (or Helvetica); text_paragraphs and text_edit paragraph Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 37 ++++ crates/automation/src/tools.rs | 10 +- crates/automation/tests/automation.rs | 5 + crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 54 +++++ crates/edit/src/text.rs | 289 +++++++++++++++++++++++++- crates/engine/src/lib.rs | 20 +- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/edit_text_ui.rs | 25 ++- crates/ui-egui/tests/editing.rs | 4 +- parity/acrobat-features.toml | 26 +-- 11 files changed, 441 insertions(+), 35 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f9bc39a5..5f9f117d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -297,6 +297,43 @@ impl Automation { .collect(); json!({ "page": page + 1, "count": lines.len(), "lines": lines }) } + "text_paragraphs" => { + let page = self.page(&a)?; + let doc = self.doc(&a)?; + let info = &doc.info.pages[page]; + let r = |x: f32| (x as f64 * 100.0).round() / 100.0; + let blocks: Vec = doc + .text_blocks(page) + .iter() + .enumerate() + .map(|(i, b)| { + let (u, v) = (info.user_to_view(b.rect[0] as f32, b.rect[1] as f32), info.user_to_view(b.rect[2] as f32, b.rect[3] as f32)); + json!({ + "paragraph": i + 1, + "text": b.text, + "lines": b.lines.iter().map(|l| l + 1).collect::>(), + "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], + "font": b.base_font, + "size": (b.size * 100.0).round() / 100.0, + }) + }) + .collect(); + json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) + } + "text_edit" if a.get("paragraph").is_some() => { + let page = self.page(&a)?; + let n = self.doc(&a)?.text_blocks(page).len(); + let k = a.int("paragraph")?; + if k < 1 || k as usize > n { + return Err(ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1))); + } + let text = a.str("text")?.to_owned(); + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text })?; + if let Some(b) = self.doc(&a)?.text_blocks(page).get(k as usize - 1) { + out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + } + out + } "text_edit" => { let page = self.page(&a)?; let n = self.doc(&a)?.text_lines(page).len(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4b806f69..e1f6222e 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -816,11 +816,15 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("text_edit", "Edit a line of text", "Replace the text of one line (from text_lines) in place, keeping its position, size and colour. Its own font is reused when it can show every character; otherwise the line is set in Helvetica (the result shows the font used). Text that no available font can show is refused. Undoable.") + t("text_paragraphs", "List paragraphs", "The paragraphs on a page (lines grouped by font, size, alignment and spacing): number, text, its line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.") + .ro() + .cmd("edit.edit_text") + .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. Its own font is reused when it can show every character; otherwise the line is set in Helvetica (the result shows the font used). Text that no available font can show is refused. Undoable.") .cmd("edit.edit_text") .with(schema( - json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, "text": { "type": "string" } }), - &["doc", "page", "line", "text"], + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" } }), + &["doc", "page", "text"], )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6f135b31..77073839 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -647,6 +647,11 @@ fn editing_existing_text_through_tools() { assert_eq!(page_text(&mut a, doc)[1], "Section two"); assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 2, "line": 9, "text": "x" })), Err(ToolError::InvalidArgs(_)))); assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + let paras = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 })); + assert_eq!(paras["paragraphs"][0]["text"], "Page 3"); + let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 3, "paragraph": 1, "text": "Part three" })); + assert_eq!(r["paragraph"]["text"], "Part three"); + assert!(matches!(a.call("text_edit", &json!({ "doc": doc, "page": 3, "paragraph": 4, "text": "x" })), Err(ToolError::InvalidArgs(_)))); } #[test] diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 27efbc49..d6e7ec47 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -588,7 +588,7 @@ pub fn marks_present(doc: &Document) -> Vec { mod flatten; pub use flatten::flatten; pub mod text; -pub use text::{LineEdit, TextLine, replace_line, text_lines}; +pub use text::{LineEdit, TextBlock, TextLine, replace_block, replace_line, text_blocks, text_lines}; pub mod added; pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, list_added, update_content}; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 656db84e..c93248c4 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -288,3 +288,57 @@ fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { assert!(text::replace_line(&mut doc, 0, 0, "Ωmega").is_err()); assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line"); } + +#[test] +fn paragraphs_are_found_and_rewrapped() { + let mut doc = text_page( + "BT 0 0 1 rg /F1 10 Tf 12 TL 72 700 Td (The quick brown fox) Tj T* (jumps over the) Tj T* (lazy dog.) Tj ET \ + BT /F1 10 Tf 72 600 Td (Next paragraph) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, ["The quick brown fox jumps over the lazy dog.", "Next paragraph"]); + assert_eq!(blocks[0].lines, [0, 1, 2]); + let width = blocks[0].rect[2] - blocks[0].rect[0]; + let next = blocks[1].rect; + let long = "PrintCraft rewraps a paragraph to its own width when its text changes, keeping the font, size, colour and line spacing."; + assert_eq!(text::replace_block(&mut doc, 0, 0, long).unwrap().substituted, None); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, long); + assert!(blocks[0].lines.len() > 3, "more lines: {}", blocks[0].lines.len()); + for i in &blocks[0].lines { + assert!(lines[*i].rect[2] - lines[*i].rect[0] <= width + 1.0, "line {} fits", lines[*i].text); + } + // Same left edge, same spacing (12 pt). + assert!((lines[0].rect[0] - 72.0).abs() < 0.01); + let spacing = lines[blocks[0].lines[0]].origin_baseline() - lines[blocks[0].lines[1]].origin_baseline(); + assert!((spacing - 12.0).abs() < 0.01, "{spacing}"); + // The next paragraph is untouched, and the new text is blue like the old. + assert_eq!(blocks[1].text, "Next paragraph"); + assert_eq!(blocks[1].rect, next); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("0 0 1 rg"), "{content}"); + // Shorter text: fewer lines. + let mut doc = doc; + text::replace_block(&mut doc, 0, 0, "Short.").unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1)); +} + +fn page_content_bytes(doc: &Document, page: usize) -> Vec { + let p = printcraft_model::pages(doc).swap_remove(page); + let c = p.dict.get(b"Contents").unwrap(); + match &*doc.resolve(c) { + printcraft_cos::Object::Stream(s) => s.decoded().unwrap(), + printcraft_cos::Object::Array(a) => a + .iter() + .flat_map(|x| match &*doc.resolve(x) { + printcraft_cos::Object::Stream(s) => s.decoded().unwrap(), + _ => Vec::new(), + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 257ad84b..4bda803f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -35,6 +35,82 @@ pub struct TextLine { pub decodable: bool, stream: usize, ops: Vec, + /// Where the line starts: text matrix (text space), the state there, and its `BT`. + origin: Origin, +} + +/// Text and graphics state captured for rewriting. +#[derive(Clone, Debug, PartialEq)] +struct TextState { + font: Option<(String, f64)>, + char_spacing: f64, + word_spacing: f64, + scale: f64, + leading: f64, + rise: f64, + fill: Vec, +} + +impl TextState { + fn of(ts: &Ts) -> Self { + TextState { + font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), + char_spacing: ts.char_spacing, + word_spacing: ts.word_spacing, + scale: ts.scale, + leading: ts.leading, + rise: ts.rise, + fill: ts.fill.clone(), + } + } + + /// Operators that set this state (inside a text object). + fn ops(&self) -> Vec { + let n = printcraft_content::num; + let mut v = Vec::new(); + if let Some((f, size)) = &self.font { + v.push(Op::new("Tf", vec![Object::name(f), n(*size)])); + } + v.push(Op::new("Tc", vec![n(self.char_spacing)])); + v.push(Op::new("Tw", vec![n(self.word_spacing)])); + v.push(Op::new("Tz", vec![n(self.scale * 100.0)])); + v.push(Op::new("TL", vec![n(self.leading)])); + v.push(Op::new("Ts", vec![n(self.rise)])); + v.extend(self.fill.iter().cloned()); + v + } +} + +#[derive(Clone, Debug, PartialEq)] +struct Origin { + tm: [f64; 6], + /// Text-space units → user space (the scale of the text rendering matrix's y axis). + k: f64, + baseline: f64, + x: f64, + state: TextState, + bt_op: usize, + bt_state: TextState, +} + +/// A paragraph: consecutive lines in one stream with the same font and size, aligned on the left +/// and regularly spaced. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBlock { + /// The lines' text joined with spaces (lines that ended in a hyphen are joined directly). + pub text: String, + pub rect: [f64; 4], + pub base_font: String, + pub size: f64, + /// Indexes into [`text_lines`]. + pub lines: Vec, +} + +impl TextLine { + /// The baseline's height in user space. + pub fn origin_baseline(&self) -> f64 { + self.origin.baseline + } } /// What replacing a line did. @@ -47,6 +123,8 @@ pub struct LineEdit { #[derive(Clone)] struct Ts { ctm: Matrix, + /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). + fill: Vec, font: Option<(Vec, Rc)>, size: f64, char_spacing: f64, @@ -82,6 +160,11 @@ fn page_dict(doc: &Document, page: usize) -> Result, Rc>) -> Vec { let mut out = Vec::new(); - let mut ts = Ts { ctm: Matrix::IDENTITY, font: None, size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0, rise: 0.0 }; + let mut ts = Ts { + ctm: Matrix::IDENTITY, + fill: Vec::new(), + font: None, + size: 0.0, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + }; + let mut at_bt = (0usize, ts.clone()); let mut stack: Vec = Vec::new(); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; @@ -117,6 +211,12 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap ts.fill = vec![op.clone()], + b"sc" | b"scn" => { + ts.fill.retain(|o| o.is("cs")); + ts.fill.push(op.clone()); } b"Tf" => { ts.size = op.num(1).unwrap_or(ts.size); @@ -216,6 +316,10 @@ fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap Result, EditErro decodable: s.decodable, stream: si, ops: vec![s.op], + origin: Origin { + tm: s.tm.0, + k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(), + baseline: s.baseline, + x: s.start_x, + state: TextState::of(&s.state), + bt_op: s.bt_op, + bt_state: TextState::of(&s.bt_state), + }, }); } last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0))); @@ -278,6 +391,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro Ok(lines) } +/// Text → the bytes that show it in the chosen font. +type Encoder = Box Option>>; + /// The substitute font's resource name. const SUBSTITUTE: &[u8] = b"PCEdHelv"; @@ -365,3 +481,174 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) } + +/// The paragraphs on a page (0-based). +pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { + let lines = text_lines(doc, page)?; + Ok(group_blocks(&lines)) +} + +fn group_blocks(lines: &[TextLine]) -> Vec { + let mut blocks: Vec = Vec::new(); + let mut gap: Option = None; + for (i, l) in lines.iter().enumerate() { + let joins = i > 0 + && blocks.last().is_some_and(|b| { + let prev = &lines[*b.lines.last().expect("non-empty")]; + let g = prev.origin.baseline - l.origin.baseline; + prev.stream == l.stream + && prev.font == l.font + && (prev.size - l.size).abs() < 0.01 + && (prev.origin.x - l.origin.x).abs() < l.size + && g > l.size * 0.8 + && g < l.size * 2.5 + && gap.is_none_or(|first| (g - first).abs() < first * 0.2) + }); + if joins && let Some(b) = blocks.last_mut() { + let prev = &lines[*b.lines.last().expect("non-empty")]; + gap.get_or_insert(prev.origin.baseline - l.origin.baseline); + if b.text.ends_with('-') { + b.text.pop(); + } else { + b.text.push(' '); + } + b.text.push_str(l.text.trim()); + b.rect = [b.rect[0].min(l.rect[0]), b.rect[1].min(l.rect[1]), b.rect[2].max(l.rect[2]), b.rect[3].max(l.rect[3])]; + b.lines.push(i); + } else { + gap = None; + blocks.push(TextBlock { text: l.text.trim().to_string(), rect: l.rect, base_font: l.base_font.clone(), size: l.size, lines: vec![i] }); + } + } + blocks +} + +/// Greedy word wrapping to `width` with `advance` giving a string's width. +fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { + let mut out = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if !line.is_empty() && advance(&candidate) > width { + out.push(std::mem::take(&mut line)); + line = word.to_string(); + } else { + line = candidate; + } + } + if !line.is_empty() || out.is_empty() { + out.push(line); + } + out +} + +/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the +/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font +/// (or Helvetica when the font can't show the text), size and colour. +pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result { + let lines = text_lines(doc, page)?; + let blocks = group_blocks(&lines); + let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); + let first = members[0]; + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let streams = content_streams(doc, &p.dict); + let (stream_obj, data) = streams.get(first.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?; + let ops = parse(&data).ops; + let text = text.split_whitespace().collect::>().join(" "); + let o = &first.origin; + let (font_name, size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let reuse = metrics.as_ref().is_some_and(|m| m.encode(&text).is_some()); + // Widths in user space: the paragraph's width, and each candidate line's advance. + let k = o.k.max(1e-6); + // A paragraph keeps its width; a single line grows to the right, up to the page's margin. + let mut width = (b.rect[2] - b.rect[0]).max(size * k); + if members.len() == 1 { + let crop = p.crop(doc); + width = width.max(crop[2] - 36.0 - b.rect[0]); + } + let advance = |s: &str| -> f64 { + let t = match (&metrics, reuse) { + (Some(m), true) => m + .encode(s) + .map(|bytes| { + m.codes(&bytes) + .iter() + .map(|(c, l)| m.width(*c) * size + o.state.char_spacing + if m.is_space(*c, *l) { o.state.word_spacing } else { 0.0 }) + .sum::() + }) + .unwrap_or(0.0), + _ => printcraft_fonts::helvetica_width(s, size), + }; + t * o.state.scale * k + }; + let wrapped = wrap(&text, width + 0.5, advance); + let mut substituted = None; + let (show_font, encode): (String, Encoder) = if reuse { + let m = metrics.clone().expect("checked"); + (font_name.clone(), Box::new(move |s: &str| m.encode(s))) + } else { + let win = printcraft_fonts::win_ansi(&text); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + if text.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", b.base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts_res.set(SUBSTITUTE.to_vec(), Object::Dict(f)); + substituted = Some("Helvetica".to_string()); + (String::from_utf8_lossy(SUBSTITUTE).into_owned(), Box::new(|s: &str| Some(printcraft_fonts::win_ansi(s)))) + }; + // Line spacing in text space: the paragraph's own, or 1.2 × the size for one line. + let lead = if members.len() > 1 { (members[0].origin.baseline - members[1].origin.baseline) / k } else { size * 1.2 }; + let n = printcraft_content::num; + let mut block_ops = vec![Op::new("BT", vec![])]; + let mut state = o.state.clone(); + state.font = Some((show_font, size)); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + for (i, line) in wrapped.iter().enumerate() { + if i > 0 { + block_ops.push(Op::new("Td", vec![n(0.0), n(-lead)])); + } + let bytes = encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))?; + block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + block_ops.push(Op::new("ET", vec![])); + // What the text after the paragraph expects: the state at its BT. + block_ops.extend(o.bt_state.ops().into_iter().filter(|op| !op.is("Tf") || o.bt_state.font.is_some())); + let drop: std::collections::HashSet = members.iter().flat_map(|l| l.ops.iter().copied()).collect(); + let mut new_ops = Vec::with_capacity(ops.len() + block_ops.len()); + for (i, op) in ops.into_iter().enumerate() { + if i == o.bt_op { + new_ops.append(&mut block_ops); + } + if !drop.contains(&i) { + new_ops.push(op); + } + } + let mut dict = match &*doc.resolve(&stream_obj) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&new_ops)))); + let contents: Vec = streams.iter().enumerate().map(|(i, (o, _))| if i == first.stream { Object::Ref(new) } else { o.clone() }).collect(); + if substituted.is_some() { + res.set(b"Font".to_vec(), Object::Dict(fonts_res)); + } + let page_ref = p.obj; + doc.update_dict(page_ref, |d| { + d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) }); + if substituted.is_some() { + d.set(b"Resources".to_vec(), Object::Dict(res)); + } + })?; + Ok(LineEdit { substituted }) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3305c12b..5c6aa94a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -31,7 +31,7 @@ pub use printcraft_forms::{ }; pub use printcraft_a11y as a11y; -pub use printcraft_edit::TextLine; +pub use printcraft_edit::{TextBlock, TextLine}; pub use printcraft_fonts::{ScriptOutline, script_outline}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user @@ -183,6 +183,11 @@ impl Document { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| printcraft_edit::text_blocks(&e.cos, page).ok()).unwrap_or_default() + } + /// A counter that changes with every edit (for caches of derived data). pub fn edit_generation(&self) -> u64 { self.generation @@ -747,6 +752,13 @@ pub enum Edit { line: usize, text: String, }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + }, /// Order tabs manually: move a field one place earlier or later on its page. MoveInTabOrder { name: String, @@ -913,7 +925,7 @@ impl Edit { Edit::SetInitialView(_) => "Change initial view".into(), Edit::SetAltText { .. } => "Set alternate text".into(), Edit::MarkDecorative { .. } => "Mark figure as decorative".into(), - Edit::EditTextLine { .. } => "Edit text".into(), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => "Edit text".into(), Edit::AddHeaderFooter { replace: false, .. } => "Add header & footer".into(), Edit::AddHeaderFooter { .. } => "Update header & footer".into(), Edit::AddWatermark { replace: false, .. } => "Add watermark".into(), @@ -1074,6 +1086,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::SetAltText { .. } | Edit::MarkDecorative { .. } | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } | Edit::Flatten { .. } => { if p.modify() { Ok(()) @@ -1257,6 +1270,9 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - Edit::EditTextLine { page, line, text } => { printcraft_edit::replace_line(doc, *page, *line, text)?; } + Edit::EditTextBlock { page, block, text } => { + printcraft_edit::replace_block(doc, *page, *block, text)?; + } Edit::MarkDecorative { figure } => { let r = printcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 386c72cf..77f36d78 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -139,7 +139,7 @@ pub struct DocView { pub pending_edit: Option, /// Edit text: the lines per page (with the document generation they were read at), and the /// line being edited. - pub(crate) edit_lines: HashMap)>, + pub(crate) edit_lines: HashMap)>, pub line_editor: Option, /// Commenting state: selected comment, gestures, composer. pub comments: crate::comments::CommentView, @@ -1228,7 +1228,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let lines = match view.edit_lines.get(&i) { Some((g, l)) if *g == generation => l.clone(), _ => { - let l = doc.text_lines(i); + let l = doc.text_blocks(i); view.edit_lines.insert(i, (generation, l.clone())); l } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index eadeb5f6..5192e8ba 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1,5 +1,5 @@ -//! Edit a PDF ▸ Edit text: boxes around the lines of existing text; click one to edit it in place -//! (Enter or clicking away applies, Esc cancels). +//! Edit a PDF ▸ Edit text: boxes around the paragraphs of existing text; click one to edit it in +//! place (⌘Enter or clicking away applies and rewraps it to the box, Esc cancels). use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use printcraft_engine::Edit; @@ -9,11 +9,11 @@ use crate::canvas::{DocView, PageXform}; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); -/// A line being edited. +/// A paragraph being edited. #[derive(Clone, Debug, PartialEq)] pub struct LineEditor { pub page: usize, - pub line: usize, + pub block: usize, pub text: String, original: String, rect: Rect, @@ -29,7 +29,7 @@ pub(crate) fn page_input( xf: &PageXform, page: usize, info: &DocInfo, - lines: &[printcraft_engine::TextLine], + lines: &[printcraft_engine::TextBlock], view: &mut DocView, ) -> bool { let boxes: Vec = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect(); @@ -43,10 +43,11 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Text); if resp.clicked() { let l = &lines[hit]; - let scale = boxes[hit].height() / ((l.rect[3] - l.rect[1]).max(1.0) as f32 + 4.0); + // Screen pixels per point, from the box's width. + let scale = (boxes[hit].width() - 4.0) / ((l.rect[2] - l.rect[0]).max(1.0) as f32); view.line_editor = Some(LineEditor { page, - line: hit, + block: hit, text: l.text.clone(), original: l.text.clone(), rect: boxes[hit], @@ -66,21 +67,23 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option { |ui| { egui::Frame::NONE.fill(Color32::WHITE).stroke(Stroke::new(1.5, ACCENT)).inner_margin(egui::Margin::symmetric(2, 0)).show(ui, |ui| { let r = ui.add( - egui::TextEdit::singleline(&mut ed.text) + egui::TextEdit::multiline(&mut ed.text) .id(egui::Id::new("edit-text-line-input")) .font(egui::FontId::proportional(ed.size)) .text_color(Color32::BLACK) .frame(egui::Frame::NONE) - .desired_width(ed.rect.width().max(120.0)), + .desired_width(ed.rect.width().max(120.0)) + .desired_rows(((ed.rect.height() / (ed.size * 1.2)).round() as usize).max(1)), ); if ed.focus { r.request_focus(); ed.focus = false; } let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if r.lost_focus() { + } else if apply || r.lost_focus() { done = Some(true); } }); @@ -89,7 +92,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option { match done { Some(apply) => { let ed = view.line_editor.take()?; - (apply && ed.text != ed.original).then_some(Edit::EditTextLine { page: ed.page, line: ed.line, text: ed.text }) + (apply && ed.text != ed.original).then_some(Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text }) } None => None, } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6823a52e..25214bba 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -607,10 +607,10 @@ fn editing_existing_text_in_place() { h.drop_at(at); h.run_steps(3); let ed = h.state().views[0].line_editor.clone().expect("the line opens for editing"); - assert_eq!((ed.page, ed.line, ed.text.as_str()), (0, 0, "Page 1")); + assert_eq!((ed.page, ed.block, ed.text.as_str()), (0, 0, "Page 1")); h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); h.run_steps(1); - h.key_press(egui::Key::Enter); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); h.run_steps(4); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index dc69b96e..7c61a89b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1781,9 +1781,9 @@ tier = "P0" milestone = "M7" status = "partial" commands = ["edit.edit_text"] -tools = ["text_lines", "text_edit"] -evidence = ["crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] -notes = "line by line: click a line of existing text and retype it in place (position, size and colour kept; later lines untouched); paragraph boxes and multi-line reflow are next" +tools = ["text_lines", "text_paragraphs", "text_edit"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); per-character formatting changes and moving or resizing boxes are next" [[feature]] id = "edit.text-reflow" @@ -1791,11 +1791,11 @@ title = "Reflow edited text inside its paragraph box" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.edit_text"] +tools = ["text_edit"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "edited paragraphs are rewrapped to the box width with the font's own advance widths and the original line spacing, starting at the original text matrix; text state and fill colour restored after; a single line grows to the right margin" [[feature]] id = "edit.paragraph-detection" @@ -1803,11 +1803,11 @@ title = "Detect paragraphs and text blocks for editing" area = "C" tier = "P0" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.edit_text"] +tools = ["text_paragraphs"] +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "lines grouped into paragraphs by stream, font, size, left alignment and regular baseline spacing; hyphenated line ends are joined" [[feature]] id = "edit.font-reuse-substitution"