M5: Text boxes write rich text (/RC XHTML and /DS) in step with their text and style

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:11:12 -04:00
parent 9372b25d0b
commit 03fca33d10
3 changed files with 42 additions and 3 deletions

View File

@ -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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;");
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", 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!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</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));
}

View File

@ -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("<?xml") && rc.contains("<p dir=\"ltr\">Caf\u{e9} (draft) \u{2014} 100%</p>") && rc.contains("color:#FF0000"), "{rc}");
}
#[test]

View File

@ -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"