diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index b406bb43e..e135b4e87 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -2,7 +2,7 @@ //! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the //! displayed page (y down), like every other tool. -use pdfcraft_engine::{AddedContent, AddedText, Edit, FontFamily, TextAlign}; +use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign}; use serde_json::{Value, json}; use crate::comments::parse_color; @@ -61,6 +61,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { other => return Err(bad(format!("unknown align {other:?}"))), }; } + if let Some(c) = a.opt_num("char_spacing")? { + if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) { + return Err(bad(format!("char_spacing must be between -{ADDED_CHAR_SPACING_LIMIT} and {ADDED_CHAR_SPACING_LIMIT} points"))); + } + t.char_spacing = c; + } Ok(()) } @@ -85,6 +91,7 @@ impl Automation { v["size"] = json!(t.size); v["bold"] = json!(t.bold); v["italic"] = json!(t.italic); + v["char_spacing"] = json!(t.char_spacing); } AddedContent::Image(_) => v["type"] = json!("image"), } @@ -164,8 +171,8 @@ impl Automation { if a.get("text").is_some() { return Err(bad("images have no text")); } - if ["font", "size", "bold", "italic", "color", "align"].iter().any(|k| a.get(k).is_some()) { - return Err(bad("font, size, bold, italic, color and align apply to text")); + if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) { + return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text")); } if let Some(deg) = a.opt_int("rotate")? { if deg.rem_euclid(90) != 0 { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 8e7738054..8f687263a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -582,7 +582,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right, char_spacing (points after each character). Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -594,6 +594,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], )), @@ -607,7 +608,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }), &["doc", "page", "path"], )), - t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.") + t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align, char_spacing in points) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, @@ -615,6 +616,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, "image": { "type": "string", "description": "Replace the picture with this file." }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 295caddda..a75ef990a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4396,6 +4396,24 @@ fn content_index_out_of_range_is_an_error_not_a_panic() { assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); } +/// Character spacing on added text (#4245): set when adding or restyling, listed back, and refused +/// out of range or on images. +#[test] +fn added_text_takes_character_spacing() { + let dir = workdir("content-char-spacing"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Spaced", "at": [20, 20], "char_spacing": 2 })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], 2.0); + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": -0.5 })); + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5); + for bad in [json!(1000), json!(-1000), json!("wide")] { + let args = json!({ "doc": doc, "page": 1, "index": 1, "char_spacing": bad }); + assert!(matches!(a.call("content_update", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } + assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["char_spacing"], -0.5); +} + fn added_rotation_image(dir: &Path) { let mut png = Vec::new(); let mut enc = png::Encoder::new(&mut png, 2, 1); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index a4fa1b9c5..4157c57e4 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -20,6 +20,9 @@ use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, p const TAG: &str = "Added"; +/// The most character spacing an added text takes either way, in points: as far as the font size goes. +pub const CHAR_SPACING_LIMIT: f64 = 500.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Family { #[default] @@ -116,6 +119,8 @@ pub struct AddedText { pub size: f64, pub color: [f64; 3], pub align: Align, + /// Extra space after every character, in points: the PDF's character spacing (`Tc`). + pub char_spacing: f64, } impl Default for AddedText { @@ -129,6 +134,7 @@ impl Default for AddedText { size: 12.0, color: [0.0; 3], align: Align::Left, + char_spacing: 0.0, } } } @@ -192,7 +198,9 @@ impl Content { fn scaled(self, factor: f64) -> Content { match self { _ if factor == 1.0 => self, - Content::Text(t) => Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, ..t }), + Content::Text(t) => { + Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, char_spacing: t.char_spacing * factor, ..t }) + } Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }), } } @@ -230,6 +238,12 @@ fn norm(r: [f64; 4]) -> [f64; 4] { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } +/// The advance of `s` in the item's standard font: its glyphs' widths and the character spacing +/// after each of them, as `Tc` moves the pen (ISO 32000-2 §9.3.2). +fn latin_width(t: &AddedText, s: &str) -> f64 { + t.family.width(s, t.size, t.bold, t.italic) + t.char_spacing * s.chars().count() as f64 +} + /// The lines of a text item after wrapping to its box width. pub fn lines(t: &AddedText) -> Vec { wrapped(t).into_iter().map(|(line, _)| line).collect() @@ -264,7 +278,7 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; + let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { latin_width(t, s) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { @@ -292,10 +306,10 @@ fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result, Edit let mut atoms = Vec::new(); for piece in pieces { match piece { - Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))), + Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size + t.char_spacing))), Piece::Latin(s) => atoms.extend(s.chars().map(|c| { let text = c.to_string(); - let w = t.family.width(&text, t.size, t.bold, t.italic); + let w = latin_width(t, &text); (text, w) })), } @@ -459,8 +473,9 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let width = pieces .iter() .map(|p| match p { - Piece::Latin(s) => t.family.width(s, t.size, t.bold, t.italic), - Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::() * t.size, + Piece::Latin(s) => latin_width(t, s), + // Each cluster is shown on its own, so the spacing follows each once. + Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance * t.size + t.char_spacing).sum::(), }) .sum(); Ok((pieces, width)) @@ -469,7 +484,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 /// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing /// then reports why). fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 { - shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold, t.italic)) + shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| latin_width(t, s)) } fn arabic_error(e: GlyphError, text: &str) -> EditError { @@ -651,6 +666,9 @@ fn draw_laid_shaped( } let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); + if t.char_spacing != 0.0 { + out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); + } for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() { // Shaped glyphs are painted in visual order. Keep the original line's typing order // explicitly, including marks, ligatures, digits and mixed-script punctuation. @@ -676,7 +694,7 @@ fn draw_laid_shaped( out.extend(literal(&win_ansi(s))); out.extend_from_slice(b" Tj\n"); current = Some(latin.as_str()); - pen += t.family.width(s, t.size, t.bold, t.italic) + tw * s.matches(' ').count() as f64; + pen += latin_width(t, s) + tw * s.matches(' ').count() as f64; } Piece::Shaped(clusters) => { for c in clusters { @@ -691,7 +709,7 @@ fn draw_laid_shaped( out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); out.extend(literal(&[code])); out.extend_from_slice(b" Tj\n"); - pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 }; + pen += c.advance * t.size + t.char_spacing + if c.text == " " { tw } else { 0.0 }; } } } @@ -843,9 +861,12 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: let r = text_rect(t); let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes()); + if t.char_spacing != 0.0 { + out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); + } let all = lines(t); for (i, line) in all.iter().enumerate() { - let w = t.family.width(line, t.size, t.bold, t.italic); + let w = latin_width(t, line); let x = match t.align { Align::Left | Align::Justify => r[0], Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, @@ -916,6 +937,9 @@ fn params(c: &Content) -> Dict { }), ); d.set(b"Rect".to_vec(), arr(&text_rect(t))); + if t.char_spacing != 0.0 { + d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing)); + } } Content::Image(i) => { d.set(b"Kind".to_vec(), Object::name("Image")); @@ -959,6 +983,8 @@ fn parse(doc: &Document, d: &Dict) -> Option { Some(3) => Align::Justify, _ => Align::Left, }, + // Items written before character spacing have none. + char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0), })) } b"Image" => { @@ -1059,6 +1085,11 @@ fn validate(c: &Content) -> Result<(), EditError> { if !(t.size.is_finite() && (1.0..=500.0).contains(&t.size)) { return Err(EditError::Invalid("the font size must be between 1 and 500 points".into())); } + if !(t.char_spacing.is_finite() && (-CHAR_SPACING_LIMIT..=CHAR_SPACING_LIMIT).contains(&t.char_spacing)) { + return Err(EditError::Invalid(format!( + "the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points" + ))); + } if (r[2] - r[0]).abs() < 1.0 { return Err(EditError::Invalid("the text box is too narrow".into())); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 334450e8b..76b63d82e 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6518,6 +6518,35 @@ fn added_text_is_wrapped_once_per_write() { assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines"); } +/// Character spacing on added text (#4245) is the PDF's `Tc`, kept in `/PCAdded` so the item +/// reopens with it; an item written without it reads 0. +#[test] +fn added_text_character_spacing_is_drawn_and_reopens() { + let mut doc = fixture(); + let plain = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "Approved".into(), size: 10.0, ..AddedText::default() }; + add_content(&mut doc, 0, &Content::Text(plain.clone())).unwrap(); + add_content(&mut doc, 0, &Content::Text(AddedText { char_spacing: 2.5, ..plain })).unwrap(); + let doc = reopen(&doc); + let spacings: Vec = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.char_spacing } else { f64::NAN }).collect(); + assert_eq!(spacings, [0.0, 2.5]); + let page = streams(&doc, 0); + let tc: Vec = page.iter().filter(|s| s.contains("(Approved) Tj")).map(|s| s.contains("2.5 Tc")).collect(); + assert_eq!(tc, [false, true], "{page:?}"); +} + +/// Spacing widens every measure the layout takes: the widest line and wrapping. +#[test] +fn added_text_character_spacing_widens_its_layout() { + let plain = AddedText { rect: [0.0, 0.0, 1000.0, 100.0], text: "Approved".into(), size: 10.0, ..AddedText::default() }; + let spaced = AddedText { char_spacing: 3.0, ..plain.clone() }; + // After each of the 8 letters, like `Tc`. + assert!((added::text_width(&spaced) - added::text_width(&plain) - 24.0).abs() < 1e-9); + let w = added::text_width(&plain); + let narrow = |t: &AddedText| AddedText { text: "Approved Approved".into(), rect: [0.0, 0.0, 2.0 * w + 5.0, 100.0], ..t.clone() }; + assert_eq!(added::lines(&narrow(&plain)).len(), 1); + assert_eq!(added::lines(&narrow(&spaced)).len(), 2, "the spaced words no longer fit one line"); +} + /// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not /// in its paths, and a node listing itself is visited once. #[test] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 92b1a2da6..0a9db62cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -56,7 +56,8 @@ pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnific pub use pdfcraft_cos::Algorithm; pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; pub use pdfcraft_edit::added::{ - paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, }; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 58f55fec6..381a10d39 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6505,6 +6505,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + char_spacing: 0.0, }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); let after = session.get(id).unwrap(); @@ -6544,6 +6545,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + char_spacing: 0.0, }, }, Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] }, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1622273e3..a54f1e406 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -577,19 +577,27 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad pdfcraft_engine::TextAlign::Right => egui::Align::Max, pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, }; + // Character spacing shows as the page will draw it, scaled like the font. + let letter_spacing = (t.style.char_spacing * f64::from(zoom)) as f32; + let mut spaced = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, wrap_width, halign); + ui.fonts_mut(|f| f.layout_job(job)) + }; ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(font) - .desired_width(width) - .desired_rows(1) - .horizontal_align(halign) - .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), - ); + let mut edit = egui::TextEdit::multiline(&mut t.text) + .font(font.clone()) + .desired_width(width) + .desired_rows(1) + .horizontal_align(halign) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text(tl!("Type text")) + .id_salt("added-text-edit"); + if letter_spacing != 0.0 { + edit = edit.layouter(&mut spaced); + } + let resp = ui.add(edit); if t.focus { resp.request_focus(); t.focus = false; @@ -945,6 +953,17 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: out } +/// The editor's layout of added text with character spacing. The page puts the spacing after every +/// character, the last of a line included (`latin_width`), while epaint puts it only between +/// characters: the wrap width gives up one spacing so both break lines at the same place. +fn spaced_job(text: &str, font: &egui::FontId, color: Color32, letter_spacing: f32, wrap_width: f32, halign: egui::Align) -> egui::text::LayoutJob { + let format = egui::TextFormat { font_id: font.clone(), color, extra_letter_spacing: letter_spacing, ..Default::default() }; + let mut job = egui::text::LayoutJob::single_section(text.to_owned(), format); + job.wrap.max_width = (wrap_width - letter_spacing).max(0.0); + job.halign = halign; + job +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -993,4 +1012,44 @@ mod tests { let i = flipped("Flip vertical"); assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom"); } + + /// #4245: the page counts the spacing after a line's last character too, so a line that fits + /// only without it wraps in the saved text and must wrap in the editor. + #[test] + fn editor_wraps_where_the_saved_text_does_with_trailing_spacing() { + let rows = |wrap: f32| { + let mut n = 0; + let mut h = egui_kittest::Harness::new_ui_state( + move |ui, n: &mut usize| { + let font = egui::FontId::proportional(14.0); + let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, wrap, egui::Align::Min); + *n = ui.fonts_mut(|f| f.layout_job(job)).rows.len(); + }, + n, + ); + h.run(); + n = *h.state(); + n + }; + // The text's width without the trailing spacing, as epaint measures it. + let one_line = rows(f32::INFINITY); + assert_eq!(one_line, 1); + let width = { + let mut w = 0.0; + let mut h = egui_kittest::Harness::new_ui_state( + |ui, w: &mut f32| { + let font = egui::FontId::proportional(14.0); + let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, f32::INFINITY, egui::Align::Min); + *w = ui.fonts_mut(|f| f.layout_job(job)).rect.width(); + }, + w, + ); + h.run(); + w = *h.state(); + w + }; + // Page width = width + 10 (spacing after the last character) > width + 5: the page wraps. + assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too"); + assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits"); + } } diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 749f09dcc..9849920ac 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -2,8 +2,9 @@ //! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing; //! horizontal scale and character spacing. One panel serves a paragraph being edited (with the //! fonts already on its page and the spacing controls) and added text (the standard families, -//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and -//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so. +//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists, +//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown +//! disabled and says so. //! //! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. @@ -197,9 +198,10 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an // icon and its control, `half` wide. let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); - ui.add_enabled_ui(extras.is_some(), |ui| { - let mut fallback = Extras::default(); - let e = extras.as_deref_mut().unwrap_or(&mut fallback); + let editing = extras.is_some(); + let mut fallback = Extras::default(); + let e = extras.as_deref_mut().unwrap_or(&mut fallback); + ui.add_enabled_ui(editing, |ui| { row(ui, |ui| { labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); @@ -222,16 +224,20 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: .on_disabled_hover_text(tip); }); }); - // Any value the engine takes can be typed; the lists hold the common ones. - row(ui, |ui| { + }); + // Any value the engine takes can be typed; the lists hold the common ones. Added text has + // character spacing of its own (#4245), but no scale yet. + row(ui, |ui| { + ui.add_enabled_ui(editing, |ui| { let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale")); let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" }; number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale"))); - ui.add_space(8.0); - let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); - let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; - number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); }); + ui.add_space(8.0); + let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); + let value = if editing { &mut e.char_spacing } else { &mut look.char_spacing }; + let spacing = Number { value, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; + number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); }); out.changed = (look.clone(), extras.as_deref().copied()) != before; diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 82334fa12..61272e944 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -763,6 +763,27 @@ fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { ); } +/// Added text takes character spacing from Format text (#4245), typed like a paragraph's; its +/// line spacing and horizontal scale stay disabled until added text has them. +#[test] +fn format_text_sets_character_spacing_for_added_text() { + use egui::accesskit::Role; + use egui_kittest::kittest::NodeT; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let disabled = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_role_and_label(Role::SpinButton, label).accesskit_node().is_disabled(); + assert!(disabled(&h, "Horizontal scale"), "added text has no scale yet"); + assert!(!disabled(&h, "Character spacing"), "added text takes character spacing"); + h.get_by_role_and_label(Role::SpinButton, "Character spacing").click(); + h.run_steps(2); + h.event(egui::Event::Text("2.5".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(h.state().text_style.char_spacing, 2.5); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b9c4f6c88..1c2f060db 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1984,10 +1984,10 @@ area = "C" tier = "P1" milestone = "M7" status = "shipped" -commands = ["edit.edit_text"] -tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] -notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" +commands = ["edit.edit_text", "edit.text"] +tools = ["text_edit", "page_add_text", "content_update"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing", "crates/edit/src/tests.rs::added_text_character_spacing_is_drawn_and_reopens", "crates/edit/src/tests.rs::added_text_character_spacing_widens_its_layout", "crates/automation/tests/it/automation.rs::added_text_takes_character_spacing"] +notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list. Added text takes character spacing too (Tc, kept in /PCAdded and rewrapped); horizontal scaling for added text is not there yet" [[feature]] id = "edit.lists" @@ -2616,7 +2616,7 @@ status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/it/content.rs::format_text_rows_line_up_and_fit_the_panel"] -notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." +notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline, line spacing and horizontal scale on added text." [[feature]] id = "edit.adobe-express-edits"