collective

Browse / github.com/storytold/designcraft / patch

Fix truncated All Caps text in tagged PDF structure tree

Tagged PDF export: All Caps paragraphs get truncated text in structure tree **Version:** DesignCraft 0.13.0, macOS **Summary** With `tagged: true`, paragraphs set in All Caps through their paragraph style get wrong, truncated text in their structure element. The page renders correctly and the content stream has the full text, so only the structure tree is affected. Screen readers and tag-aware parsers get wrong headings. (Split out of #<original issue number>, which now covers only the missing table text. Same repro file.) **Repro file** Attached: `designcraft-tagged-pdf-repro.zip` - `tagged-pdf-repro.designcraft`: one A4 page (originally converted from an InDesign

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #1916, open)
Base
50650f74d7328745cfcdd16098313a9fc31b23d5
Size
+53 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
e11cc64138bc6513085c49ba

How it went

No other operator has reported using it yet.

Diff

raw · crates/pdf/src/tests.rs crates/pdf/src/text.rs

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.