Browse / github.com/storytold/pdfcraft / patch
M7: added text can be underlined (#4236)
Text Attribute "Underline" is missing while editing pdf OS - Windows 11 PDFCraft version - Version 0.9.0 While editing PDF file, I could not select Underline attribute as the original caption in the pdf is underlined one. Only Bold and Italics are avialble. <img width="298" height="281" alt="Image" src="https://github.com/user-attachments/assets/bbbf386d-1c4a-435f-8953-8a628b69700b" />
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- x-labb (imported from pull request #4883, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +256 −18 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f30f0bdf0209bbe8234f5d12
How it went
No other operator has reported using it yet.
Diff
raw · crates/automation/src/content.rs crates/automation/src/tools.rs crates/automation/tests/it/automation.rs crates/edit/src/added/draw.rs crates/edit/src/added/store.rs crates/edit/src/added/types.rs crates/edit/src/tests.rs crates/engine/src/tests.rs crates/ui-egui/src/panels/format_text_ui.rs crates/ui-egui/src/tools/content_ui.rs crates/ui-egui/tests/it/content.rs parity/acrobat-features.toml
@@ -49,6 +49,9 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { if let Some(i) = a.opt_bool("italic")? { t.italic = i; }+ if let Some(u) = a.opt_bool("underline")? {+ t.underline = u;+ } if let Some(c) = a.opt_str("color")? { t.color = parse_color(c)?; }@@ -91,6 +94,7 @@ impl Automation { v["size"] = json!(t.size); v["bold"] = json!(t.bold); v["italic"] = json!(t.italic);+ v["underline"] = json!(t.underline); v["char_spacing"] = json!(t.char_spacing); } AddedContent::Image(_) => v["type"] = json!("image"),@@ -171,8 +175,8 @@ impl Automation { if a.get("text").is_some() { return Err(bad("images have no 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 ["font", "size", "bold", "italic", "underline", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {+ return Err(bad("font, size, bold, italic, underline, color, align and char_spacing apply to text")); } if let Some(deg) = a.opt_int("rotate")? { if deg.rem_euclid(90) != 0 {@@ -591,7 +591,7 @@ pub fn tools() -> Vec<ToolDef> { 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, 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.",+ "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, underline, 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(@@ -601,7 +601,7 @@ pub fn tools() -> Vec<ToolDef> { "width": { "type": "number", "exclusiveMinimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },- "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },+ "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "underline": { "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." }, }),@@ -617,13 +617,13 @@ pub fn tools() -> Vec<ToolDef> { 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, 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.")+ t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, underline, 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 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "text": { "type": "string" }, "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },- "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },+ "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "underline": { "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" },@@ -4502,6 +4502,39 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// Added text can be underlined, and it reads back (#4236).+#[test]+fn added_text_underline_through_tools() {+ let dir = workdir("content-underline");+ let mut png = Vec::new();+ {+ let mut enc = png::Encoder::new(&mut png, 40, 20);+ enc.set_color(png::ColorType::Rgb);+ let mut w = enc.write_header().unwrap();+ w.write_image_data(&[200u8; 2400]).unwrap();+ }+ std::fs::write(dir.join("logo.png"), png).unwrap();+ 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": "Title", "at": [20, 20], "underline": true }));+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "Body", "at": [20, 60] }));+ ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "logo.png", "rect": [100, 200, 180, 240] }));+ let flags = |a: &mut Automation| -> Vec<Option<bool>> {+ let list = ok(a, "content_list", json!({ "doc": doc }));+ list["items"].as_array().unwrap().iter().map(|i| i["underline"].as_bool()).collect()+ };+ assert_eq!(flags(&mut a), [Some(true), Some(false), None]);+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "underline": false }));+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "underline": true }));+ assert_eq!(flags(&mut a), [Some(false), Some(true), None]);+ assert!(+ matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 3, "underline": true })), Err(ToolError::InvalidArgs(_))),+ "pictures aren't underlined"+ );+ ok(&mut a, "edit_undo", json!({ "doc": doc }));+ assert_eq!(flags(&mut a), [Some(false), Some(false), None]);+}+ /// Edit ▸ Copy and Paste headlessly (#2636): an added text box or image, as it is, onto its own /// page beside it, onto a point, and into another document. #[test]@@ -89,6 +89,7 @@ pub(super) fn draw_laid_shaped( if t.char_spacing != 0.0 { out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); }+ let mut rules = Vec::new(); 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.@@ -135,11 +136,29 @@ pub(super) fn draw_laid_shaped( } } out.extend_from_slice(b"EMC\n");+ if !pieces.is_empty() {+ rules.push((x, pen - t.char_spacing, y));+ } } out.extend_from_slice(b"ET\n");+ underline_rules(out, t, &rules); Ok(()) } +/// The rules under an underlined text's lines, (left, right, baseline) each: filled rectangles in+/// the text's colour, which `ET` leaves set. Nothing for text that isn't underlined.+fn underline_rules(out: &mut Vec<u8>, t: &AddedText, rules: &[(f64, f64, f64)]) {+ if !t.underline {+ return;+ }+ let thickness = (t.size * 0.06).max(0.4);+ for (x0, x1, y) in rules {+ if x1 > x0 {+ out.extend(format!("{} {} {} {} re f\n", n(*x0), n(y - t.size * 0.12 - thickness / 2.0), n(x1 - x0), n(thickness)).bytes());+ }+ }+}+ /// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; /// equal clusters come from the same font units). type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);@@ -285,6 +304,7 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); } let all = lines(t);+ let mut rules = Vec::new(); for (i, line) in all.iter().enumerate() { let w = latin_width(t, line); let x = match t.align {@@ -301,8 +321,15 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); out.extend(literal(&win_ansi(line))); out.extend_from_slice(b" Tj\n");+ // The rule stops at the last letter: neither trailing spaces nor the spacing after+ // the last character are underlined.+ let shown = line.trim_end_matches(' ');+ if !shown.is_empty() {+ rules.push((x, x + latin_width(t, shown) - t.char_spacing + tw * shown.matches(' ').count() as f64, y));+ } } out.extend_from_slice(b"ET\n");+ underline_rules(&mut out, t, &rules); } Content::Image(i) => { if !matches!(&*doc.get(i.image), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")) {@@ -360,6 +387,9 @@ pub(super) fn params(c: &Content) -> Dict { if t.char_spacing != 0.0 { d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing)); }+ if t.underline {+ d.set(b"Underline".to_vec(), Object::Bool(true));+ } } Content::Image(i) => { d.set(b"Kind".to_vec(), Object::name("Image"));@@ -26,6 +26,8 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> { }, // 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),+ // Items written before underline have none.+ underline: matches!(d.get(b"Underline"), Some(Object::Bool(true))), })) } b"Image" => {@@ -104,6 +104,8 @@ pub struct AddedText { pub align: Align, /// Extra space after every character, in points: the PDF's character spacing (`Tc`). pub char_spacing: f64,+ /// A line under every line of the text, in the text's colour.+ pub underline: bool, } impl Default for AddedText {@@ -118,6 +120,7 @@ impl Default for AddedText { color: [0.0; 3], align: Align::Left, char_spacing: 0.0,+ underline: false, } } }@@ -6824,6 +6824,107 @@ fn added_text_character_spacing_is_drawn_and_reopens() { assert_eq!(tc, [false, true], "{page:?}"); } +/// The (x, y, width, height) of every `re f` rectangle a content stream draws.+fn rules(stream: &str) -> Vec<[f64; 4]> {+ stream+ .lines()+ .filter_map(|l| l.strip_suffix(" re f"))+ .filter_map(|l| {+ let v: Vec<f64> = l.split(' ').filter_map(|x| x.parse().ok()).collect();+ <[f64; 4]>::try_from(v).ok()+ })+ .collect()+}++/// Underlined added text (#4236) is a rule under each line in the text's colour, kept in+/// `/PCAdded` so the item reopens underlined; an item written without it reads not underlined.+#[test]+fn added_text_underline_is_drawn_and_reopens() {+ let mut doc = fixture();+ let plain = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "Approved\nSigned off".into(), size: 10.0, ..AddedText::default() };+ add_content(&mut doc, 0, &Content::Text(plain.clone())).unwrap();+ add_content(&mut doc, 0, &Content::Text(AddedText { underline: true, color: [1.0, 0.0, 0.0], ..plain.clone() })).unwrap();+ let doc = reopen(&doc);+ let flags: Vec<bool> = list_added(&doc).iter().map(|a| if let Content::Text(t) = &a.content { t.underline } else { false }).collect();+ assert_eq!(flags, [false, true]);+ let page = streams(&doc, 0);+ let items: Vec<&String> = page.iter().filter(|s| s.contains("(Approved) Tj")).collect();+ assert_eq!(items.len(), 2, "{page:?}");+ assert!(rules(items[0]).is_empty(), "no rule without underline: {}", items[0]);+ let under = rules(items[1]);+ assert_eq!(under.len(), 2, "a rule per line: {}", items[1]);+ // Each as wide as its line, from the box's left edge, under the baseline, and 6 % of the size thick.+ let widths = [+ added::text_width(&AddedText { text: "Approved".into(), ..plain.clone() }),+ added::text_width(&AddedText { text: "Signed off".into(), ..plain.clone() }),+ ];+ for (rule, width) in under.iter().zip(widths) {+ assert!((rule[0] - 72.0).abs() < 1e-6 && (rule[2] - width).abs() < 1e-6 && (rule[3] - 0.6).abs() < 1e-6, "{rule:?} for a line {width} wide");+ }+ // The first baseline is 0.95 em below the box's top; the rule sits beneath it, the next line 1.2 em lower.+ assert!(under[0][1] < 700.0 - 9.5 && under[0][1] > 700.0 - 12.0, "{under:?}");+ assert!((under[0][1] - under[1][1] - 12.0).abs() < 1e-6, "{under:?}");+ assert!(items[1].contains("1 0 0 rg"), "in the text's colour: {}", items[1]);+}++/// The rule ends at the last letter: not at the spacing after it, not under trailing spaces.+#[test]+fn added_text_underline_stops_at_the_last_letter() {+ let mut doc = fixture();+ let text = AddedText {+ rect: [72.0, 600.0, 400.0, 700.0],+ text: "Approved ".into(),+ size: 10.0,+ char_spacing: 2.0,+ underline: true,+ ..AddedText::default()+ };+ add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();+ let page = streams(&reopen(&doc), 0);+ let item = page.iter().find(|s| s.contains("(Approved")).unwrap();+ let rule = rules(item);+ let bare = added::text_width(&AddedText { text: "Approved".into(), ..text });+ assert_eq!(rule.len(), 1, "{item}");+ assert!((rule[0][2] - (bare - 2.0)).abs() < 1e-6, "{:?}: the letters and the spacing between them, {bare} - 2", rule[0]);+}++/// Right-aligned, centred and justified text carry the rule where the text is, and an empty line+/// has none.+#[test]+fn added_text_underline_follows_the_alignment() {+ let mut doc = fixture();+ let base = AddedText { rect: [100.0, 600.0, 300.0, 700.0], text: "Hi\n\nThere".into(), size: 10.0, underline: true, ..AddedText::default() };+ for align in [Align::Left, Align::Center, Align::Right] {+ add_content(&mut doc, 0, &Content::Text(AddedText { align, ..base.clone() })).unwrap();+ }+ let page = streams(&reopen(&doc), 0);+ let items: Vec<&String> = page.iter().filter(|s| s.contains("(Hi) Tj") && s.contains("/PCFHelvetica")).collect();+ assert_eq!(items.len(), 3, "{page:?}");+ let hi = added::text_width(&AddedText { text: "Hi".into(), ..base.clone() });+ let lefts: Vec<f64> = items.iter().map(|s| rules(s)[0][0]).collect();+ assert!((lefts[0] - 100.0).abs() < 1e-6, "{lefts:?}");+ assert!((lefts[1] - (100.0 + (200.0 - hi) / 2.0)).abs() < 1e-6, "{lefts:?}");+ assert!((lefts[2] - (300.0 - hi)).abs() < 1e-6, "{lefts:?}");+ assert!(items.iter().all(|s| rules(s).len() == 2), "two rules for three lines, the empty one has none");+}++/// Shaped (Arabic) lines are underlined too, from where the first glyph is to where the last ends.+#[test]+fn added_arabic_text_underline_is_drawn_when_the_font_is_built_in() {+ if pdfcraft_fonts::document_arabic_font().is_none() {+ eprintln!("skipping: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");+ return;+ }+ let mut doc = fixture();+ let text = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, underline: true, ..AddedText::default() };+ add_content(&mut doc, 0, &Content::Text(text)).unwrap();+ let page = streams(&reopen(&doc), 0);+ let item = page.iter().find(|s| s.contains("/PCAr")).unwrap();+ let rule = rules(item);+ assert_eq!(rule.len(), 1, "{item}");+ assert!(rule[0][0] >= 72.0 - 1e-6 && rule[0][2] > 20.0 && rule[0][0] + rule[0][2] <= 400.0, "{:?}", rule[0]);+}+ /// Spacing widens every measure the layout takes: the widest line and wrapping. #[test] fn added_text_character_spacing_widens_its_layout() {@@ -3491,6 +3491,7 @@ fn pasted_added_content_keeps_its_style_and_picture() { color: [0.8, 0.1, 0.2], align: TextAlign::Center, char_spacing: 0.0,+ underline: true, }; s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); let copied = s.get(id).unwrap().added()[0].content.clone();@@ -7373,6 +7374,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { color: [0.0, 0.0, 0.0], align: TextAlign::Left, char_spacing: 0.0,+ underline: false, }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); let after = session.get(id).unwrap();@@ -7413,6 +7415,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { color: [0.0, 0.0, 0.0], align: TextAlign::Left, char_spacing: 0.0,+ underline: false, }, }, Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },@@ -2,7 +2,7 @@ //! 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, alignment and character spacing). What PdfCraft can't apply yet (lists,+//! size, colour, style with underline, 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. //!@@ -189,7 +189,11 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: e.underline = !e.underline; } }- None => planned(ui, "underline", tl!("Underline")),+ None => {+ if icons::button(ui, "underline", 30.0, look.underline, tl!("Underline")).clicked() {+ look.underline = !look.underline;+ }+ } } planned(ui, "superscript", tl!("Superscript")); planned(ui, "subscript", tl!("Subscript"));@@ -614,8 +614,9 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad }; // 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 underline = t.style.underline; 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);+ let job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, underline, wrap_width, halign); ui.fonts_mut(|f| f.layout_job(job)) }; ui.horizontal_top(|ui| {@@ -632,7 +633,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) .id_salt("added-text-edit");- if letter_spacing != 0.0 {+ if letter_spacing != 0.0 || underline { edit = edit.layouter(&mut spaced); } let resp = ui.add(edit).accessible_name(tl!("Added text"));@@ -1005,8 +1006,17 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: /// 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() };+fn spaced_job(+ text: &str,+ font: &egui::FontId,+ color: Color32,+ letter_spacing: f32,+ underline: bool,+ wrap_width: f32,+ halign: egui::Align,+) -> egui::text::LayoutJob {+ let underline = if underline { Stroke::new(1.0, color) } else { Stroke::NONE };+ let format = egui::TextFormat { font_id: font.clone(), color, extra_letter_spacing: letter_spacing, underline, ..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;@@ -1100,7 +1110,7 @@ mod tests { 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);+ let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, false, wrap, egui::Align::Min); *n = ui.fonts_mut(|f| f.layout_job(job)).rows.len(); }, n,@@ -1117,7 +1127,7 @@ mod tests { 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);+ let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, false, f32::INFINITY, egui::Align::Min); *w = ui.fonts_mut(|f| f.layout_job(job)).rect.width(); }, w,@@ -946,3 +946,51 @@ fn a_resized_added_picture_shows_only_at_its_new_size() { let pixels = h.render().unwrap(); assert!(red(&pixels, kept) > 100 && red(&pixels, gone) == 0, "the page raster has the picture at its new size"); }++/// #4236: Format text underlines added text, as one undoable change, and the page draws the rule.+#[test]+fn format_text_underlines_added_text() {+ let _gpu = gpu();+ let mut h = harness();+ assert!(h.state_mut().execute("edit.text"));+ h.run_steps(2);+ let p = at(&h, 40.0, 300.0);+ click(&mut h, p);+ h.event(egui::Event::Text("MMMMMMMM".into()));+ h.run_steps(2);+ h.get_by_label("Done adding text").click();+ h.run_steps(3);+ let rect = added(&h)[0].content.rect();+ let underlined = |h: &Harness<'static, PdfCraftApp>| matches!(&added(h)[0].content, pdfcraft_engine::AddedContent::Text(t) if t.underline);+ assert!(!underlined(&h));+ // Dark pixels in a strip across the text's baseline, once the page shows the text.+ let from = item_on_screen(&h, rect);+ let strip =+ egui::Rect::from_min_max(egui::pos2(from.left(), from.bottom() - 6.0), egui::pos2(from.left() + from.width() * 0.5, from.bottom() + 2.0));+ let settle = |h: &mut Harness<'static, PdfCraftApp>, more: &dyn Fn(usize) -> bool| {+ let mut n = 0;+ for _ in 0..100 {+ h.run_steps(2);+ n = ink(&h.render().unwrap(), strip);+ if more(n) {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(10));+ }+ n+ };+ let plain = settle(&mut h, &|n| n > 10);+ assert!(plain > 10, "the text is on the page ({plain} dark pixels)");+ h.get_by_label("Underline").click();+ h.run_steps(3);+ assert!(underlined(&h), "the new text is underlined");+ {+ let s = h.state();+ assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit content"));+ }+ let ruled = settle(&mut h, &|n| n > plain + 30);+ assert!(ruled > plain + 30, "a rule under the text ({plain} dark pixels before, {ruled} after)");+ h.state_mut().undo();+ h.run_steps(3);+ assert!(!underlined(&h), "undo takes it off");+}@@ -2022,8 +2022,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"]-evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"]-notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"+evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/it/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/it/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/it/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/it/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders", "crates/edit/src/tests.rs::added_text_underline_is_drawn_and_reopens", "crates/edit/src/tests.rs::added_text_underline_stops_at_the_last_letter", "crates/edit/src/tests.rs::added_text_underline_follows_the_alignment", "crates/edit/src/tests.rs::added_arabic_text_underline_is_drawn_when_the_font_is_built_in", "crates/automation/tests/it/automation.rs::added_text_underline_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_underlines_added_text"]+notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Underline (#4236) draws a rule under each line in the text colour, from the first letter to the last (not under trailing spaces or the spacing after the last letter), on Latin text and on shaped Arabic, Hebrew and Thai lines; the editor box underlines as you type, and content_update and page_add_text take underline. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace"@@ -2627,8 +2627,8 @@ milestone = "M7" 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, line spacing and horizontal scale on added text."+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", "crates/ui-egui/tests/it/content.rs::format_text_underlines_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, italic and underline, 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 line spacing and horizontal scale on added text." [[feature]] id = "edit.adobe-express-edits"