Browse / github.com/storytold/pdfcraft / patch
M7: Add text can have a background fill of its own (#4789)
Add background colour to Add Text annotations ## Request Allow users to set an interior/background colour for text added with the Add Text tool, independently from the text colour. ## Scope - Provide a background-colour control when creating and editing Add Text annotations. - Persist it in the PDF annotation appearance and retain it after save/reopen. - Keep text colour and background colour independently configurable. - Expose the capability through the existing headless comment/add-text tooling with end-to-end coverage. Related: comment formatting and font selection are tracked separately in #3737 and PR #3710.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- AdrianTM (imported from pull request #4912, open)
- Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf- Size
- +264 −9 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
99a9a55f04109571d5ba4e19
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md 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
@@ -745,3 +745,5 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.+- **2026-10-27 (M7, #4789):** Add text can have a background fill of its own, apart from the text colour: Format text ▸ Fill colour (and No fill), `background` on `page_add_text`/`content_update` (`none` removes it), listed by `content_list`; drawn as a filled rectangle under the text and kept through save and reopen.+@@ -52,6 +52,10 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { if let Some(c) = a.opt_str("color")? { t.color = parse_color(c)?; }+ // "none" takes the background off.+ if let Some(c) = a.opt_str("background")? {+ t.background = if c.eq_ignore_ascii_case("none") { None } else { Some(parse_color(c)?) };+ } if let Some(al) = a.opt_str("align")? { t.align = match al { "left" => TextAlign::Left,@@ -92,6 +96,10 @@ impl Automation { v["bold"] = json!(t.bold); v["italic"] = json!(t.italic); v["char_spacing"] = json!(t.char_spacing);+ v["background"] = t.background.map_or(Value::Null, |[r, g, b]| {+ let byte = |x: f64| (x.clamp(0.0, 1.0) * 255.0).round() as u8;+ json!(format!("#{:02x}{:02x}{:02x}", byte(r), byte(g), byte(b)))+ }); } AddedContent::Image(_) => v["type"] = json!("image"), }@@ -171,8 +179,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", "color", "background", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {+ return Err(bad("font, size, bold, italic, color, background, 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, color (#RRGGBB or a name), background (a fill behind the text box, same colour forms), 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(@@ -602,6 +602,7 @@ pub fn tools() -> Vec<ToolDef> { "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" },+ "background": { "type": "string", "description": "Fill behind the text box (#RRGGBB or a name), independent of color." }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }),@@ -617,13 +618,14 @@ 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, color, background ('none' removes it), 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" },+ "background": { "type": "string", "description": "Fill behind the text box (#RRGGBB or a name); none removes it." }, "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" },@@ -4459,6 +4459,30 @@ fn printing_through_tools() { assert!(matches!(a.call("doc_print", &json!({ "doc": doc, "pages": "99", "path": "x.pdf" })), Err(ToolError::InvalidArgs(_)))); } +/// #4789: a text box takes a background of its own, apart from the text colour; content_list+/// reports it; `none` takes it off; a bad colour is refused and changes nothing.+#[test]+fn added_text_takes_a_background_through_tools() {+ let dir = workdir("text-background");+ 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": "NOTE", "at": [20, 20], "color": "#0000ff", "background": "#ffff00" }));+ let list = ok(&mut a, "content_list", json!({ "doc": doc }));+ assert_eq!(list["items"][0]["background"], "#ffff00");+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "background": "#00ff00" }));+ assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["background"], "#00ff00");+ assert!(matches!(+ a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "background": "not-a-colour" })),+ Err(ToolError::InvalidArgs(_))+ ));+ assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["background"], "#00ff00", "a refused colour changes nothing");+ ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "background": "none" }));+ assert!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][0]["background"].is_null());+ // Without one, the item has none.+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "PLAIN", "at": [20, 60] }));+ assert!(ok(&mut a, "content_list", json!({ "doc": doc }))["items"][1]["background"].is_null());+}+ #[test] fn adding_content_through_tools() { let dir = workdir("content");@@ -256,6 +256,23 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict .into_bytes(); match c { Content::Text(t) => {+ // The background goes down first, under the text, whichever way the text is drawn.+ if let Some([br, bg, bb]) = t.background {+ let r = text_rect(t);+ out.extend(+ format!(+ "{} {} {} rg {} {} {} {} re f\n",+ n(br.clamp(0.0, 1.0)),+ n(bg.clamp(0.0, 1.0)),+ n(bb.clamp(0.0, 1.0)),+ n(r[0]),+ n(r[1]),+ n(r[2] - r[0]),+ n(r[3] - r[1])+ )+ .bytes(),+ );+ } let base = t.family.base_font(t.bold, t.italic); let name = font_name(base); let mut font = Dict::new();@@ -360,6 +377,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 let Some(bg) = t.background {+ d.set(b"Background".to_vec(), arr(&bg));+ } } Content::Image(i) => { d.set(b"Kind".to_vec(), Object::name("Image"));@@ -3,6 +3,16 @@ use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; use super::*; +/// A colour of exactly three finite numbers, each possibly an indirect reference. Anything else+/// (a wrong length, a name among the numbers) is damaged, and the whole colour is dropped rather+/// than read as some other one.+fn colour(doc: &Document, o: &Object) -> Option<[f64; 3]> {+ let array = doc.resolve(o);+ let [r, g, b] = array.as_array()?.as_slice() else { return None };+ let part = |x: &Object| doc.resolve(x).as_f64().filter(|v| v.is_finite());+ Some([part(r)?, part(g)?, part(b)?])+}+ fn parse(doc: &Document, d: &Dict) -> Option<Content> { let r = nums(doc, d.get(b"Rect")); let rect: [f64; 4] = r.try_into().ok()?;@@ -26,6 +36,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 backgrounds have none; a damaged colour is none too.+ background: d.get(b"Background").and_then(|o| colour(doc, o)), })) } b"Image" => {@@ -131,6 +143,9 @@ fn validate(c: &Content) -> Result<(), EditError> { "the character spacing must be between -{CHAR_SPACING_LIMIT} and {CHAR_SPACING_LIMIT} points" ))); }+ if t.color.iter().chain(t.background.iter().flatten()).any(|v| !v.is_finite()) {+ return Err(EditError::Invalid("the colours must be numbers".into()));+ } if (r[2] - r[0]).abs() < 1.0 { return Err(EditError::Invalid("the text box is too narrow".into())); }@@ -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,+ /// The fill behind the text box, if any: independent of the text colour.+ pub background: Option<[f64; 3]>, } impl Default for AddedText {@@ -118,6 +120,7 @@ impl Default for AddedText { color: [0.0; 3], align: Align::Left, char_spacing: 0.0,+ background: None, } } }@@ -845,6 +845,36 @@ fn assert_marker_scan_matches_legacy(doc: &Document, label: &str, marks: &[MarkK scan } +/// A damaged /Background is dropped whole, not read as some other colour, while a valid colour+/// whose numbers are indirect objects is read: otherwise the next move or restyle writes the+/// invented colour into the file, or loses the real one.+#[test]+fn added_text_background_is_read_as_an_exact_numeric_triple() {+ let mut doc = fixture();+ let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Note".into(), background: Some([0.0, 0.0, 1.0]), ..AddedText::default() };+ add_content(&mut doc, 0, &Content::Text(text)).unwrap();+ let item = list_added(&doc)[0].obj;+ let indirect = doc.add(Object::Real(0.5));+ let with = |doc: &mut Document, background: Object| {+ let Object::Stream(mut stream) = (*doc.get(item)).clone() else { panic!("an item is a stream") };+ let Some(Object::Dict(mut added)) = stream.dict.get(b"PCAdded").cloned() else { panic!("an item has its parameters") };+ added.set(b"Background".to_vec(), background);+ stream.dict.set(b"PCAdded".to_vec(), Object::Dict(added));+ doc.set(item, Object::Stream(stream));+ let Content::Text(t) = &list_added(doc)[0].content else { panic!() };+ t.background+ };+ let num = |v: f64| Object::Real(v);+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), num(0.0), num(0.0)])), Some([1.0, 0.0, 0.0]));+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), Object::Ref(indirect), num(0.0)])), Some([1.0, 0.5, 0.0]), "indirect numbers are read");+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), Object::name("Bad"), num(0.0), num(0.0)])), None, "a name among the numbers");+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), Object::name("Bad"), num(0.0)])), None, "a name in place of a number");+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), num(0.0)])), None, "too short");+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), num(0.0), num(0.0), num(0.0)])), None, "too long");+ assert_eq!(with(&mut doc, Object::Array(vec![num(1.0), num(f64::NAN), num(0.0)])), None, "not finite");+ assert_eq!(with(&mut doc, Object::name("Red")), None, "not an array");+}+ #[test] fn combined_marker_scan_matches_the_independent_scans() { let empty = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"]);@@ -957,6 +987,48 @@ fn combined_marker_scan_matches_the_independent_scans() { assert_eq!(text.size, 24.0); } +/// #4789: a background fill sits under the text, in its own colour, and survives save and reopen;+/// taking it off removes the fill; no background draws no fill; a damaged or non-finite colour+/// is refused or ignored.+#[test]+fn added_text_background_is_a_fill_under_the_text() {+ let mut doc = fixture();+ let text = AddedText {+ rect: [72.0, 600.0, 300.0, 700.0],+ text: "Approved".into(),+ size: 14.0,+ color: [0.0, 0.0, 1.0],+ background: Some([1.0, 1.0, 0.0]),+ ..AddedText::default()+ };+ add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();+ let doc = reopen(&doc);+ let Content::Text(t) = &list_added(&doc)[0].content else { panic!() };+ assert_eq!(t.background, Some([1.0, 1.0, 0.0]), "kept through save and reopen");+ assert_eq!(t.color, [0.0, 0.0, 1.0], "the text colour is its own");+ let page = streams(&doc, 0).join("\n");+ let fill = page.find("1 1 0 rg").expect(&page);+ let draw = page.find("(Approved) Tj").expect(&page);+ assert!(fill < draw, "the fill comes before the text: {page}");+ assert!(page[fill..].contains(" re f"), "{page}");+ assert!(page.contains("0 0 1 rg"), "the text keeps its colour: {page}");++ // Off again.+ let mut doc = doc;+ update_content(&mut doc, 0, 0, &Content::Text(AddedText { background: None, ..text.clone() })).unwrap();+ let doc = reopen(&doc);+ let Content::Text(t) = &list_added(&doc)[0].content else { panic!() };+ assert_eq!(t.background, None);+ assert!(!streams(&doc, 0).join("\n").contains("re f"), "no fill without a background");++ // A colour that is not a number is refused and changes nothing.+ let mut doc = doc;+ let bad = AddedText { background: Some([f64::NAN, 0.0, 0.0]), ..text };+ assert!(update_content(&mut doc, 0, 0, &Content::Text(bad)).is_err());+ let Content::Text(t) = &list_added(&doc)[0].content else { panic!() };+ assert_eq!(t.background, None);+}+ #[test] fn added_text_and_images_are_page_content_that_stays_editable() { let mut doc = fixture();@@ -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,+ background: None, }; 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,+ background: None, }; 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,+ background: None, }, }, Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },@@ -193,6 +193,20 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: } planned(ui, "superscript", tl!("Superscript")); planned(ui, "subscript", tl!("Subscript"));+ // Added text can also have a fill behind its box; a paragraph of the page cannot.+ if extras.is_none() {+ let fill = fill_button(ui, t, look.background);+ egui::Popup::menu(&fill).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {+ if ui.selectable_label(look.background.is_none(), tl!("No fill")).clicked() {+ look.background = None;+ ui.close();+ }+ if let Some(picked) = crate::comments::swatch_grid(ui, look.background) {+ look.background = Some(picked);+ ui.close();+ }+ });+ } }); // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an@@ -326,6 +340,25 @@ fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Respon crate::theme::hand(resp).on_hover_text(tl!("Colour")) } +/// The fill behind the text as a swatch that opens the palette; an empty box with a slash for none.+fn fill_button(ui: &mut egui::Ui, t: &Tokens, color: Option<[f64; 3]>) -> egui::Response {+ let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Fill colour")));+ let fill = rect.shrink(4.0);+ match color {+ Some(c) => {+ let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);+ ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b));+ }+ None => {+ ui.painter().line_segment([fill.left_bottom(), fill.right_top()], Stroke::new(1.0, t.border));+ }+ }+ let stroke = if resp.hovered() { t.text } else { t.border };+ ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside);+ crate::theme::hand(resp).on_hover_text(tl!("Fill colour"))+}+ #[cfg(test)] mod tests { #[test]@@ -602,8 +602,14 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad if let Some(source_rect) = source_rect.filter(|_| !background_ready) { ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); }- // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655).- ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE);+ // A background of its own shows while typing, under the text: the page's copy of the+ // item is left out of the preview, so nothing else would draw it.+ let fill = t.style.background.map(|c| {+ let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);+ Color32::from_rgb(r, g, b)+ });+ // The box is white in every theme (or the fill), so the caret must not follow the theme (pale in dark mode) (#655).+ ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(fill.unwrap_or(Color32::WHITE)); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); // Laid out as the page draws it, so opening the box doesn't show it realigned. // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter.@@ -628,7 +634,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Keep the page editor's original inset; the theme's larger button padding // otherwise shifts narrow right-aligned boxes away from their text edge. .margin(egui::Margin::symmetric(4, 2))- .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })+ .background_color(fill.unwrap_or(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");@@ -811,6 +811,73 @@ fn format_text_sets_character_spacing_for_added_text() { assert_eq!(h.state().text_style.char_spacing, 2.5); } +/// #4789: the Format panel's Fill colour gives new text a background of its own, apart from the+/// text colour, and No fill takes it off again.+#[test]+fn format_text_panel_sets_and_clears_the_background() {+ let mut h = harness();+ assert!(h.state_mut().execute("edit.text"));+ h.run_steps(3);+ assert_eq!(h.state().text_style.background, None, "no fill by default");+ let text_colour = h.state().text_style.color;+ h.get_by_label("Fill colour").click();+ h.run_steps(3);+ h.get_by_label("Red").click();+ h.run_steps(3);+ let red = h.state().text_style.background.expect("the swatch sets a background");+ assert!(red[0] > 0.5 && red[1] < 0.5 && red[2] < 0.5, "red: {red:?}");+ assert_eq!(h.state().text_style.color, text_colour, "the text colour is its own");+ h.get_by_label("Fill colour").click();+ h.run_steps(3);+ h.get_by_label("No fill").click();+ h.run_steps(3);+ assert_eq!(h.state().text_style.background, None, "No fill takes it off");+}++/// #4789: the text editor draws the item's background while typing and when reopened: the page's+/// copy of the item is left out of the preview, so without it white text on a dark fill would be+/// white on white.+#[test]+fn the_text_editor_shows_the_background_fill() {+ let _gpu = gpu();+ // Real-time frames, so the two clicks of the double click are close enough together.+ let mut h = harness_with_step_dt(1.0 / 60.0);+ h.state_mut().text_style =+ pdfcraft_engine::AddedText { color: [1.0, 1.0, 1.0], background: Some([0.0, 0.0, 0.4]), ..pdfcraft_engine::AddedText::default() };+ 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("Hello".into()));+ crate::support::settle(&mut h);+ // A pixel just inside the editor's corner: past its focus outline, clear of the glyphs.+ let corner = |h: &mut Harness<'static, PdfCraftApp>| {+ let r = h.get_by_label("Added text").rect();+ let shot = h.render().unwrap();+ shot.get_pixel(r.min.x as u32 + 6, r.min.y as u32 + 3).0+ };+ let dark = |[r, g, b, _]: [u8; 4]| r < 40 && g < 40 && (80..140).contains(&b);+ let typing = corner(&mut h);+ assert!(dark(typing), "the new text's fill shows while typing: {typing:?}");+ h.get_by_label("Done adding text").click();+ h.run_steps(3);+ let rect = added(&h)[0].content.rect();+ let center = at(&h, ((rect[0] + rect[2]) / 2.0) as f32, (400.0 - (rect[1] + rect[3]) / 2.0) as f32);+ h.run_steps(60);+ h.hover_at(center);+ h.run_steps(1);+ for _ in 0..2 {+ for pressed in [true, false] {+ h.event(egui::Event::PointerButton { pos: center, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });+ }+ h.run_steps(1);+ }+ crate::support::settle(&mut h);+ assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "double click reopens the text");+ let reopened = corner(&mut h);+ assert!(dark(reopened), "the fill shows when the text is reopened: {reopened:?}");+}+ /// 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")@@ -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_background_is_a_fill_under_the_text", "crates/automation/tests/it/automation.rs::added_text_takes_a_background_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_panel_sets_and_clears_the_background"]+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 A box can have a background fill of its own, apart from the text colour (Format text ▸ Fill colour, No fill; `background` on `page_add_text` and `content_update`; stored in the item's parameters and drawn as a filled rectangle under the text), kept through save and reopen (#4789). It is the text box's rectangle with no padding, a solid colour with no opacity, and it is not part of the saved Add text format." [[feature]] id = "edit.find-replace"