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
@@ -5521,6 +5521,41 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() {
     assert!(!doc.dirty);
 }
 
+/// A filled form whose data has a bare `&` ("Humanitarian & Compassionate" in an IRCC form) is not well-formed XML, though
+/// Adobe's reader takes it: it laid out as nothing, and the form showed only its "Please wait" page.
+#[test]
+fn a_dynamic_xfa_form_whose_data_has_a_bare_ampersand_is_still_laid_out() {
+    let data = "<form><head><familyName>Smith & Sons</familyName></head><table><row><what0>R&D</what0></row></table></form>";
+    let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template_with_data(1, data)));
+    let mut s = Session::new().with_clock(|| 1_700_000_000);
+    let id = s.open("form.pdf", None, bytes, None).expect("opens");
+    let doc = s.get(id).unwrap();
+    assert!(doc.xfa.is_some(), "laid out: {:?}", doc.xfa_warnings);
+    let value = |n: &str| doc.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone();
+    assert_eq!(value("familyName"), vec!["Smith & Sons".to_string()]);
+    assert_eq!(value("what0"), vec!["R&D".to_string()]);
+    // what is saved back is well-formed
+    s.apply(id, Edit::SetFieldValue { name: "familyName".into(), value: FieldValue::Text("Jones & Co".into()) }).unwrap();
+    let saved = s.save_bytes(id).unwrap();
+    // the saved datasets stream itself, before any reader could repair it
+    let cos = pdfcraft_cos::Document::open(saved).unwrap();
+    let acro = cos.get(cos.root().unwrap()).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| cos.resolve(a))).unwrap();
+    let packets = acro.as_dict().and_then(|a| a.get(b"XFA").map(|x| cos.resolve(x))).unwrap();
+    // one XDP stream, or an array of packets in which the datasets one is named
+    let stream = match &*packets {
+        pdfcraft_cos::Object::Array(items) => {
+            let at = (0..items.len()).step_by(2).find(|&i| items[i].as_string().is_some_and(|s| s.to_text() == "datasets")).expect("a datasets packet");
+            cos.resolve(&items[at + 1])
+        }
+        _ => packets.clone(),
+    };
+    let pdfcraft_cos::Object::Stream(stream) = &*stream else { panic!("a stream") };
+    let text = String::from_utf8(stream.decoded().unwrap()).unwrap();
+    assert!(!text.contains("Jones & Co") && text.contains("Jones &amp; Co"), "{text}");
+    let data = pdfcraft_xfa::parse_datasets(&text).expect("the saved datasets are XML");
+    assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].head[0].familyName[0]")), Some("Jones & Co"));
+}
+
 /// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a
 /// static form: its pages are the form. Opening it used to lay the template out into new pages
 /// and write them as a revision, so even a save with no change replaced the pages.
diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs
--- a/crates/xfa/src/packets.rs
+++ b/crates/xfa/src/packets.rs
@@ -44,6 +44,11 @@ fn utf16(bytes: &[u8], from: fn([u8; 2]) -> u16) -> Option<String> {
 /// anything else (invalid UTF-8, a legacy encoding, a broken surrogate), which can be read
 /// only approximately and so must not be written back.
 pub fn decode(bytes: &[u8]) -> Option<(String, Encoding)> {
+    // a bare `&` is escaped on the way in, so the packet is XML for every reader and what is written back is too
+    decode_exactly(bytes).map(|(text, encoding)| (repair_ampersands(text), encoding))
+}
+
+fn decode_exactly(bytes: &[u8]) -> Option<(String, Encoding)> {
     match bytes {
         [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes).map(|t| (t, Encoding::Utf16Le { bom: true })),
         [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes).map(|t| (t, Encoding::Utf16Be { bom: true })),
@@ -97,6 +102,56 @@ fn to_text(bytes: &[u8]) -> String {
     }
 }
 
+/// A packet whose text has a bare `&` ("Humanitarian & Compassionate" in a filled form's data) is not well-formed XML,
+/// though Adobe's own reader takes it: such a `&` becomes `&amp;`, leaving CDATA and comments as they are.
+pub(crate) fn repair_ampersands(text: String) -> String {
+    // only the five predefined entities and numeric references are references; anything else is text
+    let valid_reference = |rest: &str| -> bool {
+        // the `;` must come within the longest name (32), so a text full of `&` is not searched to its end each time
+        let Some(end) = rest.char_indices().take(34).find(|(_, c)| *c == ';').map(|(i, _)| i).filter(|e| *e > 1 && *e <= 32) else { return false };
+        let name = &rest[1..end];
+        match name.strip_prefix('#') {
+            Some(num) => match num.strip_prefix(['x', 'X']) {
+                Some(hex) => !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()),
+                None => !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()),
+            },
+            None => matches!(name, "lt" | "gt" | "amp" | "quot" | "apos"),
+        }
+    };
+    if !text.contains('&') {
+        return text;
+    }
+    let mut out = String::with_capacity(text.len() + 16);
+    let mut rest = text.as_str();
+    let mut changed = false;
+    while let Some(at) = rest.find(['&', '<']) {
+        out.push_str(&rest[..at]);
+        rest = &rest[at..];
+        if let Some(inner) = rest.strip_prefix("<![CDATA[") {
+            let len = inner.find("]]>").map_or(rest.len(), |e| e + "<![CDATA[".len() + 3);
+            out.push_str(&rest[..len]);
+            rest = &rest[len..];
+        } else if rest.starts_with("<!--") {
+            let len = rest.find("-->").map_or(rest.len(), |e| e + 3);
+            out.push_str(&rest[..len]);
+            rest = &rest[len..];
+        } else if rest.starts_with('&') {
+            if valid_reference(rest) {
+                out.push('&');
+            } else {
+                out.push_str("&amp;");
+                changed = true;
+            }
+            rest = &rest[1..];
+        } else {
+            out.push('<');
+            rest = &rest[1..];
+        }
+    }
+    out.push_str(rest);
+    if changed { out } else { text }
+}
+
 /// Read the XFA packets, if the document has any.
 pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
     let Some(root) = doc.root() else { return Ok(None) };
@@ -127,7 +182,7 @@ pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
         if total > MAX_XDP {
             return Err(XfaError::TooLarge("the XFA packets are larger than 64 MB".into()));
         }
-        Ok(Some(to_text(&bytes)))
+        Ok(Some(repair_ampersands(to_text(&bytes))))
     };
     let mut xdp = String::new();
     match &*xfa {
@@ -163,3 +218,23 @@ pub fn read_packets(doc: &Document) -> Result<Option<Packets>, XfaError> {
     }
     Ok(Some(Packets { xdp, needs_rendering: needs_rendering.unwrap_or(false), has_fields }))
 }
+
+#[cfg(test)]
+mod repair_tests {
+    use super::repair_ampersands;
+
+    #[test]
+    fn a_bare_ampersand_is_escaped_and_the_rest_is_left_alone() {
+        assert_eq!(repair_ampersands("<a>Humanitarian & Compassionate</a>".into()), "<a>Humanitarian &amp; Compassionate</a>");
+        assert_eq!(repair_ampersands("<a>x &amp; y &#xD; &#10; &lt;</a>".into()), "<a>x &amp; y &#xD; &#10; &lt;</a>");
+        assert_eq!(
+            repair_ampersands("<a><![CDATA[ a & b ]]> c & d <!-- e & f --></a>".into()),
+            "<a><![CDATA[ a & b ]]> c &amp; d <!-- e & f --></a>"
+        );
+        assert_eq!(repair_ampersands("<a>AT&T; R&D, &, & &nbsp; &quot;</a>".into()), "<a>AT&amp;T; R&amp;D, &amp;, &amp; &amp;nbsp; &quot;</a>");
+        assert_eq!(repair_ampersands("no entities".into()), "no entities");
+        // an unterminated CDATA section or comment runs to the end and stays as it is
+        assert_eq!(repair_ampersands("<a><![CDATA[ a & b".into()), "<a><![CDATA[ a & b");
+        assert_eq!(repair_ampersands("<a><!-- a & b".into()), "<a><!-- a & b");
+    }
+}
