diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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.
+
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -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 {
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -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" },
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -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");
diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs
--- a/crates/edit/src/added/draw.rs
+++ b/crates/edit/src/added/draw.rs
@@ -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"));
diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs
--- a/crates/edit/src/added/store.rs
+++ b/crates/edit/src/added/store.rs
@@ -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()));
             }
diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs
--- a/crates/edit/src/added/types.rs
+++ b/crates/edit/src/added/types.rs
@@ -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,
         }
     }
 }
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -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();
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -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] },
diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs
--- a/crates/ui-egui/src/panels/format_text_ui.rs
+++ b/crates/ui-egui/src/panels/format_text_ui.rs
@@ -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]
diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs
--- a/crates/ui-egui/src/tools/content_ui.rs
+++ b/crates/ui-egui/src/tools/content_ui.rs
@@ -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");
diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs
--- a/crates/ui-egui/tests/it/content.rs
+++ b/crates/ui-egui/tests/it/content.rs
@@ -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")
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -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"
