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