diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -890,3 +890,41 @@ fn a_swatch_colours_a_greyscale_image() {
     let (s, _) = export(&rgb, Some("PANTONE Test"));
     assert!(!s.contains("PANTONE#20Test"), "a colour image keeps its colours");
 }
+
+/// All Caps paragraphs (#1898): each letter's text is the capital it is drawn as, so no
+/// ActualText span opens inside the paragraph's tagged span (poppler drops the text before one).
+#[test]
+fn all_caps_text_maps_without_nested_actual_text() {
+    use designcraft_doc::Capitalization;
+    use hayro_syntax::content::ops::TypedInstruction;
+    use hayro_syntax::object::Object;
+    let text = "Personal Information\nLanguages\nSkills";
+    for tracking in [50.0, 0.0] {
+        let mut d = doc_with_text(text);
+        let sid = *d.stories.keys().next().unwrap();
+        d.story_mut(sid).unwrap().format_paras(0..text.len(), |f| {
+            f.chars.capitalization = Some(Capitalization::AllCaps);
+            f.chars.tracking = Some(tracking);
+        });
+        for tagged in [false, true] {
+            let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..PdfOptions::default() }).unwrap();
+            let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
+            let (mut depth, mut nested) = (0usize, 0usize);
+            let mut ops = pdf.pages()[0].typed_operations();
+            while let Some(op) = ops.next() {
+                match op {
+                    TypedInstruction::BeginMarkedContentWithProperties(op) => {
+                        nested += usize::from(depth > 0 && matches!(op.1, Object::Dict(d) if d.contains_key(b"ActualText")));
+                        depth += 1;
+                    }
+                    TypedInstruction::BeginMarkedContent(_) => depth += 1,
+                    TypedInstruction::EndMarkedContent(_) => depth = depth.saturating_sub(1),
+                    _ => {}
+                }
+            }
+            assert_eq!(nested, 0, "tracking {tracking}, tagged {tagged}");
+            let got: String = extract_text(&bytes)[0].split_whitespace().collect();
+            assert_eq!(got, "PERSONALINFORMATIONLANGUAGESSKILLS", "tracking {tracking}, tagged {tagged}");
+        }
+    }
+}
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -69,6 +69,17 @@ fn is_marker(c: char) -> bool {
     ('\u{E000}'..='\u{E1FF}').contains(&c)
 }
 
+/// The capital an All Caps glyph is drawn as, when its source text `t` is one letter whose
+/// uppercase is one other letter: that capital's ToUnicode entry then covers it, so the glyph
+/// needs no ActualText span (which poppler mishandles inside a tagged span).
+fn all_caps_char(g: &PlacedGlyph, t: &str) -> Option<char> {
+    let mut chars = t.chars();
+    let (Some(c), None) = (chars.next(), chars.next()) else { return None };
+    let mut upper = c.to_uppercase();
+    let (Some(u), None) = (upper.next(), upper.next()) else { return None };
+    (u != c && g.gid == g.face.glyph_for(u) && g.gid != g.face.glyph_for(c)).then_some(u)
+}
+
 /// Can `b` join the run `a` is in? A missing glyph (.notdef) is a run of its own.
 fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
     b.visible
@@ -436,7 +447,10 @@ impl Exporter<'_> {
             let range = if let Some(t) = src {
                 prev_src = Some(g.byte);
                 let a = text.len();
-                text.push_str(t);
+                match all_caps_char(g, t) {
+                    Some(u) => text.push(u),
+                    None => text.push_str(t),
+                }
                 a..text.len()
             } else if g.len == 0 && prev_src == Some(g.byte) && !ranges.is_empty() {
                 // Second glyph of a multi-glyph cluster: share the cluster's text.
