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:
parent
fb6208b089
commit
695e460bbb
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"])),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue