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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 02:42:18 -04:00
parent fb6208b089
commit 695e460bbb
11 changed files with 441 additions and 35 deletions

View File

@ -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<Value> = 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::<Vec<_>>(),
"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();

View File

@ -816,11 +816,15 @@ pub fn tools() -> Vec<ToolDef> {
.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"])),

View File

@ -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]

View File

@ -588,7 +588,7 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
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};

View File

@ -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<u8> {
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(),
}
}

View File

@ -35,6 +35,82 @@ pub struct TextLine {
pub decodable: bool,
stream: usize,
ops: Vec<usize>,
/// 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<Op>,
}
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<Op> {
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<usize>,
}
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<Op>,
font: Option<(Vec<u8>, Rc<Metrics>)>,
size: f64,
char_spacing: f64,
@ -82,6 +160,11 @@ fn page_dict(doc: &Document, page: usize) -> Result<printcraft_model::Page, Edit
/// The text-showing operators of one stream with their text, font, box and baseline.
struct Shown {
op: usize,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
tm: Matrix,
state: Ts,
bt_op: usize,
bt_state: Ts,
text: String,
rect: [f64; 4],
baseline: f64,
@ -96,7 +179,18 @@ struct Shown {
fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<Vec<u8>, Rc<Metrics>>) -> Vec<Shown> {
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<Ts> = 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<V
tm = Matrix::IDENTITY;
tlm = Matrix::IDENTITY;
bt += 1;
at_bt = (i, ts.clone());
}
b"g" | b"rg" | b"k" | b"cs" => 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<V
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
out.push(Shown {
op: i,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
baseline: start.1,
@ -268,6 +372,15 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, 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<Vec<TextLine>, EditErro
Ok(lines)
}
/// Text → the bytes that show it in the chosen font.
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// 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<f64> {
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<Vec<TextBlock>, EditError> {
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines))
}
fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
let mut blocks: Vec<TextBlock> = Vec::new();
let mut gap: Option<f64> = 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<String> {
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<LineEdit, EditError> {
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::<Vec<_>>().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::<f64>()
})
.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<usize> = 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<Object> = 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 })
}

View File

@ -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<printcraft_edit::TextBlock> {
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()))?;

View File

@ -139,7 +139,7 @@ pub struct DocView {
pub pending_edit: Option<Edit>,
/// Edit text: the lines per page (with the document generation they were read at), and the
/// line being edited.
pub(crate) edit_lines: HashMap<usize, (u64, Vec<printcraft_engine::TextLine>)>,
pub(crate) edit_lines: HashMap<usize, (u64, Vec<printcraft_engine::TextBlock>)>,
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
/// 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
}

View File

@ -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<Rect> = 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<Edit> {
|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<Edit> {
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,
}

View File

@ -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();

View File

@ -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"