From 03fca33d108054560a54e650950db5d7898183f1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 08:11:12 -0400 Subject: [PATCH] M5: Text boxes write rich text (/RC XHTML and /DS) in step with their text and style Co-Authored-By: Claude Opus 5.5 --- crates/annot/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++-- crates/annot/src/tests.rs | 4 ++++ parity/acrobat-features.toml | 2 +- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index a70cf26d..f8ce3cba 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1007,6 +1007,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(f)); } border(&mut d); + rich_text(&mut d); } _ => {} } @@ -1056,6 +1057,35 @@ pub(crate) fn n(v: f64) -> String { // ── edits ─────────────────────────────────────────────────────────────────────────────────── +/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its +/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing. +fn rich_text(d: &mut Dict) { + let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default(); + let (col, size) = appearance::parse_da(d); + let hex = format!( + "#{:02X}{:02X}{:02X}", + (col[0].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[1].clamp(0.0, 1.0) * 255.0).round() as u8, + (col[2].clamp(0.0, 1.0) * 255.0).round() as u8 + ); + let align = match d.get(b"Q").and_then(Object::as_int) { + Some(1) => "center", + Some(2) => "right", + _ => "left", + }; + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """); + let body: String = text.split('\n').map(|line| format!("

{}

", esc(line.trim_end_matches('\r')))).collect(); + let style = format!( + "font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal", + n(size) + ); + let rc = format!( + "{body}" + ); + d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size)))); + d.set(b"RC".to_vec(), PdfString::text(&rc)); +} + fn touch(d: &mut Dict, meta: &Meta) { if let Some(date) = &meta.date { d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); @@ -1108,8 +1138,12 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText"); doc.update_dict(r, |d| { d.set(b"Contents".to_vec(), PdfString::text(text)); - // The rich-text version would contradict the new plain text. - d.remove(b"RC"); + // The rich-text version follows the new plain text (text boxes), or would contradict it. + if free_text { + rich_text(d); + } else { + d.remove(b"RC"); + } touch(d, meta); })?; if free_text { @@ -1338,6 +1372,7 @@ pub fn set_style( let (_, size) = appearance::parse_da(d); let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0)); d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())); + rich_text(d); } else { d.set(b"C".to_vec(), rgb(c)); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 1396b84b..8e3f25c3 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -273,6 +273,10 @@ fn text_box_text_and_colour_changes_are_drawn() { let needle = b"(Caf\xe9 \\(draft\\) \x97 100%) Tj"; assert!(raw.windows(needle.len()).any(|w| w == needle), "{}", String::from_utf8_lossy(&raw)); assert!(String::from_utf8_lossy(&raw).contains("1 0 0 rg")); + // Rich text follows the plain text and the style. + assert_eq!(text(d, b"DS"), "font: Helvetica 12pt; text-align:left; color:#FF0000"); + let rc = text(d, b"RC"); + assert!(rc.starts_with("Caf\u{e9} (draft) \u{2014} 100%

") && rc.contains("color:#FF0000"), "{rc}"); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 9f4f5c11..731945e8 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3555,7 +3555,7 @@ status = "partial" commands = ["comment.freetext"] tools = ["comment_add"] evidence = ["crates/ui-egui/tests/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/annot/src/tests.rs::text_box_text_and_colour_changes_are_drawn"] -notes = "plain text in Helvetica; rich text (/RC, /DS) is not written yet" +notes = "Helvetica text with colour, size and alignment; /RC (XHTML) and /DS are written and kept in step with edits; per-run formatting (bold/italic spans) is not yet" [[feature]] id = "comment.callout"