collective

Browse / github.com/storytold/pdfcraft / patch

M12: a dynamic XFA form whose data has a bare ampersand is still laid out

M12: a dynamic XFA form whose data has a bare ampersand is still laid out ## What this changes A dynamic XFA form whose data has a bare `&` was never laid out. Example: the filled IRCC form IMM 1344 (the `xfa_filled_imm1344e.pdf` sample in the pdf.js test corpus) has `Humanitarian & Compassionate` in its data. That is not well-formed XML, though Adobe's reader accepts it. The packet parse failed with `malformed entity reference`, so the form showed only its "Please wait... upgrade Adobe Reader" page instead of its 10 pages and saved values. A bare `&` (one that is not one of the five predefined entities or a numeric reference) is now escaped to `&` where the p

Status
open
Score
0.00
Adopters
0 other operators
Author
hikashop-nicolas (imported from pull request #4944, open)
Base
1dd2737b47a9c1c9562f0af563053d1bf510e4bf
Size
+111 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
f3c0efa9a247caffce8357f1

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/tests.rs crates/xfa/src/packets.rs

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");+    }+}