From 385313e0bf338d2dabf61ed8fb93e773e57ad361 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:07:43 -0400 Subject: [PATCH] M7: Format existing text: font family, bold/italic, size, colour and alignment for a paragraph (Format text panel live while editing; text_edit formatting arguments); centred and right-aligned paragraphs are detected Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/lib.rs | 26 +++++++- crates/automation/src/tools.rs | 14 +++- crates/automation/tests/automation.rs | 8 +++ crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 33 ++++++++++ crates/edit/src/text.rs | 92 +++++++++++++++++++++------ crates/engine/src/lib.rs | 8 ++- crates/ui-egui/src/commands.rs | 2 + crates/ui-egui/src/edit_text_ui.rs | 56 +++++++++++++++- crates/ui-egui/src/panels.rs | 17 +++++ crates/ui-egui/tests/editing.rs | 25 ++++++++ parity/acrobat-features.toml | 38 +++++------ 12 files changed, 272 insertions(+), 49 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f918f8547d..d337df460f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -388,8 +388,30 @@ impl Automation { 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 })?; + let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone(); + let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let mut style = printcraft_engine::BlockStyle { size: a.opt_num("size")?, ..Default::default() }; + if let Some(f) = a.opt_str("font")? { + let family = match f { + "helvetica" => printcraft_engine::FontFamily::Helvetica, + "times" => printcraft_engine::FontFamily::Times, + "courier" => printcraft_engine::FontFamily::Courier, + other => return Err(ToolError::InvalidArgs(format!("unknown font {other:?} (helvetica, times, courier)"))), + }; + style.family = Some((family, a.opt_bool("bold")?.unwrap_or(false), a.opt_bool("italic")?.unwrap_or(false))); + } + if let Some(c) = a.opt_str("color")? { + style.color = Some(comments::parse_color(c)?); + } + if let Some(al) = a.opt_str("align")? { + style.align = Some(match al { + "left" => printcraft_engine::TextAlign::Left, + "center" => printcraft_engine::TextAlign::Center, + "right" => printcraft_engine::TextAlign::Right, + other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), + }); + } + let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text, style })?; 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 }); } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 41ca3870b0..6411adea50 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -842,11 +842,19 @@ 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 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.") + 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. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb) and align (left, center, right); text may then be omitted. 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 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" } }), - &["doc", "page", "text"], + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, + "paragraph": { "type": "integer", "minimum": 1 }, + "text": { "type": "string" }, + "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, + "bold": { "type": "boolean" }, + "italic": { "type": "boolean" }, + "size": { "type": "number", "exclusiveMinimum": 0 }, + "color": { "type": "string", "description": "#rrggbb" }, + "align": { "type": "string", "enum": ["left", "center", "right"] } }), + &["doc", "page"], )), 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 a2ba56ff78..e9e6699dbb 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -652,6 +652,14 @@ fn editing_existing_text_through_tools() { 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(_)))); + // Formatting only: font, size, colour, alignment. + ok( + &mut a, + "text_edit", + json!({ "doc": doc, "page": 3, "paragraph": 1, "font": "times", "bold": true, "size": 20, "color": "#cc0000", "align": "center" }), + ); + let p = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 3 }))["paragraphs"][0]; + assert_eq!((p["text"].as_str(), p["font"].as_str(), p["size"].as_f64()), (Some("Part three"), Some("Times-Bold"), Some(20.0))); } #[test] diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 1ad7ffd40a..9d9ddcc1c0 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -590,7 +590,7 @@ pub use flatten::flatten; pub mod images; pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect, turn_about_centre}; pub mod text; -pub use text::{LineEdit, TextBlock, TextLine, replace_block, replace_line, text_blocks, text_lines}; +pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, 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 9dbbaeca00..6eeccf85c5 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -414,3 +414,36 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::page_images(&doc, 0).unwrap().is_empty()); assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } + +#[test] +fn paragraphs_take_new_formatting() { + let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let style = text::BlockStyle { + family: Some((added::Family::Times, true, false)), + size: Some(12.0), + color: Some([1.0, 0.0, 0.0]), + align: Some(added::Align::Center), + }; + text::rewrite_block(&mut doc, 0, 0, None, &style).unwrap(); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, before.text, "same words"); + assert_eq!((blocks[0].base_font.as_str(), blocks[0].size), ("Times-Bold", 12.0)); + // Centred in the paragraph's width. + let mid = (before.rect[0] + before.rect[2]) / 2.0; + for i in &blocks[0].lines { + let r = lines[*i].rect; + assert!(((r[0] + r[2]) / 2.0 - mid).abs() < 2.0, "line {:?} centred on {mid}", lines[*i].text); + } + assert!(String::from_utf8_lossy(&page_content_bytes(&doc, 0)).contains("1 0 0 rg")); + // Right alignment keeps lines flush with the right edge. + let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"); + let right = text::text_blocks(&doc, 0).unwrap()[0].rect[2]; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + for l in &lines { + assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 4bda803fe6..f66f9c4312 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -488,6 +488,13 @@ pub fn text_blocks(doc: &Document, page: usize) -> Result, EditEr Ok(group_blocks(&lines)) } +/// Lines that share a left edge, a centre or a right edge (left, centred or right-aligned text). +fn aligned(a: &TextLine, b: &TextLine) -> bool { + let tol = b.size.max(1.0); + let centre = |r: [f64; 4]| (r[0] + r[2]) / 2.0; + (a.origin.x - b.origin.x).abs() < tol || (centre(a.rect) - centre(b.rect)).abs() < tol || (a.rect[2] - b.rect[2]).abs() < tol +} + fn group_blocks(lines: &[TextLine]) -> Vec { let mut blocks: Vec = Vec::new(); let mut gap: Option = None; @@ -499,7 +506,7 @@ fn group_blocks(lines: &[TextLine]) -> Vec { prev.stream == l.stream && prev.font == l.font && (prev.size - l.size).abs() < 0.01 - && (prev.origin.x - l.origin.x).abs() < l.size + && aligned(prev, l) && g > l.size * 0.8 && g < l.size * 2.5 && gap.is_none_or(|first| (g - first).abs() < first * 0.2) @@ -546,6 +553,23 @@ fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { /// 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 { + rewrite_block(doc, page, block, Some(text), &BlockStyle::default()) +} + +/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BlockStyle { + /// A standard font family, bold, italic. + pub family: Option<(crate::added::Family, bool, bool)>, + /// Font size in points (user space). + pub size: Option, + /// Fill colour (RGB 0–1). + pub color: Option<[f64; 3]>, + pub align: Option, +} + +/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. +pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> 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)))?; @@ -557,13 +581,23 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) 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 text = text.unwrap_or(&b.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 (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + 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 = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some()); + // The standard font used when the paragraph's own can't be (chosen, or substituted). + let (family, bold, italic) = style.family.unwrap_or((crate::added::Family::Helvetica, false, false)); + let std_width = move |s: &str, size: f64| -> f64 { + match family { + crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size, + crate::added::Family::Times => printcraft_fonts::helvetica_width(s, size) * 0.92, + crate::added::Family::Helvetica => printcraft_fonts::helvetica_width(s, size) * if bold { 1.05 } else { 1.0 }, + } + }; // 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 { @@ -581,42 +615,62 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) .sum::() }) .unwrap_or(0.0), - _ => printcraft_fonts::helvetica_width(s, size), + _ => std_width(s, size), }; t * o.state.scale * k }; let wrapped = wrap(&text, width + 0.5, advance); let mut substituted = None; + let new_font = !reuse; 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(); + let base = family.base_font(bold, italic); 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))); + return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor {base} 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"BaseFont".to_vec(), Object::name(base)); 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)))) + let name = if style.family.is_none() { String::from_utf8_lossy(SUBSTITUTE).into_owned() } else { format!("PCEd{}", base.replace('-', "")) }; + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + if style.family.is_none() { + substituted = Some(base.to_string()); + } + (name, 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 }; + // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. + let lead = if members.len() > 1 { (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size } 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)); + if let Some([r, g, bl]) = style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } 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)])); + // Alignment: each line's offset from the left edge, in text space. + let offset = |line: &str| -> f64 { + let free = (width - advance(line)).max(0.0) / k; + match style.align { + Some(crate::added::Align::Center) => free / 2.0, + Some(crate::added::Align::Right) => free, + _ => 0.0, } + }; + let mut x = 0.0; + for (i, line) in wrapped.iter().enumerate() { + let dx = offset(line); + if i > 0 || dx != 0.0 { + block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); + } + x = dx; 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))])); } @@ -640,13 +694,13 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) 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() { + if new_font { 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() { + if new_font { d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e6b3c707b0..e446c45bea 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::{PageImage, TextBlock, TextLine}; +pub use printcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -794,6 +794,8 @@ pub enum Edit { page: usize, block: usize, text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: printcraft_edit::BlockStyle, }, /// Order tabs manually: move a field one place earlier or later on its page. MoveInTabOrder { @@ -1330,8 +1332,8 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - }; printcraft_edit::change_image(doc, *page, *index, &c)?; } - Edit::EditTextBlock { page, block, text } => { - printcraft_edit::replace_block(doc, *page, *block, text)?; + Edit::EditTextBlock { page, block, text, style } => { + printcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; } Edit::MarkDecorative { figure } => { let r = printcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d33035dedc..96b2a2c480 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -225,6 +225,8 @@ impl PrintCraftApp { "a11y.check" => self.start_accessibility_check(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; + self.left = crate::LeftPanel::Tool("edit"); + self.left_open = true; self.notify("Click text or an image to edit it"); } "edit.advanced_search" => { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index e9d7e1cda0..776f7cfb29 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -21,6 +21,48 @@ pub struct LineEditor { rect: Rect, size: f32, focus: bool, + /// The Format text panel's values, and what the paragraph had (to send only changes). + pub look: printcraft_engine::AddedText, + look0: printcraft_engine::AddedText, +} + +impl LineEditor { + /// The formatting the panel changed. + pub fn style(&self) -> printcraft_engine::BlockStyle { + let (l, o) = (&self.look, &self.look0); + printcraft_engine::BlockStyle { + family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)), + size: (l.size != o.size).then_some(l.size), + color: (l.color != o.color).then_some(l.color), + align: (l.align != o.align).then_some(l.align), + } + } + + /// After applying formatting: the paragraph now has it. + pub fn applied(&mut self) { + self.look0 = self.look.clone(); + self.original = self.text.clone(); + self.focus = true; + } +} + +/// The look shown for a paragraph: the family and weight guessed from its font's name. +fn look_of(b: &printcraft_engine::TextBlock) -> printcraft_engine::AddedText { + use printcraft_engine::FontFamily as F; + let name = b.base_font.to_ascii_lowercase(); + printcraft_engine::AddedText { + family: if name.contains("times") || name.contains("serif") && !name.contains("sans") { + F::Times + } else if name.contains("courier") || name.contains("mono") { + F::Courier + } else { + F::Helvetica + }, + bold: name.contains("bold") || name.contains("black") || name.contains("heavy"), + italic: name.contains("italic") || name.contains("oblique"), + size: (b.size * 10.0).round() / 10.0, + ..Default::default() + } } /// A selected page image, and what the pointer is doing to it. @@ -199,6 +241,8 @@ pub(crate) fn page_input( rect: boxes[hit], size: (l.size as f32 * scale).clamp(8.0, 72.0), focus: true, + look: look_of(l), + look0: look_of(l), }); } true @@ -206,6 +250,8 @@ pub(crate) fn page_input( /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option { + // Clicks outside the document (the Format text panel) keep the paragraph open. + let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let ed = view.line_editor.as_mut()?; let mut done = None; egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( @@ -229,7 +275,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView) -> Option { let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if apply || r.lost_focus() { + } else if apply || (r.lost_focus() && !outside) { done = Some(true); } }); @@ -238,7 +284,13 @@ 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::EditTextBlock { page: ed.page, block: ed.block, text: ed.text }) + let style = ed.style(); + (apply && (ed.text != ed.original || style != printcraft_engine::BlockStyle::default())).then_some(Edit::EditTextBlock { + page: ed.page, + block: ed.block, + text: ed.text, + style, + }) } None => None, } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 32cd10488b..402a504ce1 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -273,6 +273,23 @@ fn stamp_palette(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// Edit a PDF ▸ Format text: for the selected added text (one undoable change), or the style /// new text gets. fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) { + // Editing a paragraph of existing text: its formatting, applied as it changes. + if let Some((i, _)) = app.active_ids() + && let Some(ed) = app.views[i].line_editor.clone() + { + if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { + let mut ed = ed; + ed.look = look; + let edit = printcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if app.apply_edit(edit) { + ed.applied(); + } + app.views[i].line_editor = Some(ed); + } + ui.add_space(6.0); + ui.separator(); + return; + } let image = app.active_ids().and_then(|(i, id)| { let (page, index) = app.views[i].content.selected?; let doc = app.session.get(id)?; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c7f8e88ff0..e23f7d0503 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -665,3 +665,28 @@ fn editing_existing_images_on_the_page() { h.run_steps(4); assert!(h.state().session.get(id).unwrap().page_images(0).is_empty()); } + +#[test] +fn the_format_panel_restyles_the_paragraph_being_edited() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some()); + // The Format text panel shows the paragraph's look; making it bold applies at once. + h.get_by_label("FORMAT TEXT"); + h.get_by_label("B").click(); + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Edit text")); + assert_eq!(doc.text_blocks(0)[0].base_font, "Helvetica-Bold"); + assert!(s.views[0].line_editor.is_some(), "still editing"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 308245f180..a8b406153e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1827,11 +1827,11 @@ title = "Change font, size and colour" area = "C" tier = "P0" milestone = "M7" -status = "partial" -commands = ["edit.text"] -tools = ["content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable"] -notes = "for added text: family, size, bold, italic, colour; existing text needs the M7 editor" +status = "shipped" +commands = ["edit.edit_text", "edit.text"] +tools = ["text_edit", "content_update"] +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "existing paragraphs and added text: font family (Helvetica, Times, Courier) with bold and italic, size, colour; existing paragraphs are rewrapped" [[feature]] id = "edit.text-format-style" @@ -1839,11 +1839,11 @@ title = "Bold, italic, underline" area = "C" tier = "P1" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.edit_text", "edit.text"] +tools = ["text_edit"] +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "bold and italic through the standard font families; underline is not done yet" [[feature]] id = "edit.text-super-subscript" @@ -1864,10 +1864,10 @@ area = "C" tier = "P0" milestone = "M7" status = "partial" -commands = ["edit.text"] -tools = ["content_update"] -evidence = ["crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable"] -notes = "left/centre/right for added text" +commands = ["edit.edit_text", "edit.text"] +tools = ["text_edit"] +evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "left, centre, right for existing paragraphs (per wrapped line) and added text; justify is not done yet" [[feature]] id = "edit.line-paragraph-spacing" @@ -2487,11 +2487,11 @@ title = "Format panel for edited text" 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_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"] +notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open" [[feature]] id = "edit.adobe-express-edits"