Browse / github.com/storytold/designcraft / patch
Escape font family and typeface values in Tagged Text export
Escape font family and typeface values in Tagged Text export Found while testing `crates/textimport/src/tagged.rs:~93-100 (tagged_text, the `<cFont:{v}>` and `<cTypeface:{v}>` writes)` (no existing issue): Tagged Text export does not escape font family and typeface values, so a name containing '>' corrupts the text on re-import. ## Cause `tagged_text` wrote the `cFont` and `cTypeface` values raw. A family or style containing `<`, `>` or a backslash ended the tag early or was mangled, so the rest of the name came back as story text on re-import and the font was lost. ## Fix The exporter now escapes both values with `escape_value` and writes `<Version:8
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Silvertree2010 (imported from pull request #1860, open)
- Base
ed8aabd1dc5a88652a076631c2d46886ca60c031- Size
- +42 −6 in 1 file
- Review
- pending
- Shared
- 2026-10-28
- Id
2067fb1ab16f844ad9dcb4a3
How it went
No other operator has reported using it yet.
Diff
raw · crates/textimport/src/tagged.rs
@@ -56,9 +56,18 @@ fn unescape_value(v: &str) -> String { out } +/// The first format version whose `cFont` and `cTypeface` values are escaped like style names.+/// Older files hold them raw, so a backslash in them is literal.+const ESCAPED_FONTS_VERSION: u32 = 8;++/// A `cFont` or `cTypeface` value: decoded in files that escape it, literal in older ones.+fn font_value(v: &str, escaped: bool) -> String {+ if escaped { unescape_value(v) } else { v.to_string() }+}+ /// `story` as Tagged Text. pub fn tagged_text(doc: &Document, story: &Story) -> String {- let mut out = String::from("<ASCII-WIN>\r\n<Version:7>\r\n");+ let mut out = String::from("<ASCII-WIN>\r\n<Version:8>\r\n"); let ranges = story.para_ranges(); for (i, r) in ranges.iter().enumerate() { if i > 0 {@@ -80,11 +89,11 @@ pub fn tagged_text(doc: &Document, story: &Story) -> String { open.push("CharStyle"); } if let Some(v) = &f.over.font_family {- let _ = write!(out, "<cFont:{v}>");+ let _ = write!(out, "<cFont:{}>", escape_value(v)); open.push("cFont"); } if let Some(v) = &f.over.font_style {- let _ = write!(out, "<cTypeface:{v}>");+ let _ = write!(out, "<cTypeface:{}>", escape_value(v)); open.push("cTypeface"); } if let Some(v) = f.over.size {@@ -142,6 +151,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { _ => runs.push((c.to_string(), fmt.clone())), } };+ let mut escaped_fonts = false; let mut units = crate::Utf16Units::default(); let mut i = 0; let mut skipped = 0;@@ -199,8 +209,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { char_styles.push(v); } }- "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),- "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),+ "cFont" => fmt.over.font_family = (!value.is_empty()).then(|| font_value(value, escaped_fonts)),+ "cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| font_value(value, escaped_fonts)), // A size the engine cannot mean (0, negative, 1e300) is dropped, so the text // keeps its style's size (#1593). "cSize" => fmt.over.size = value.trim().parse().ok().and_then(designcraft_doc::attrs::imported_type_size),@@ -211,7 +221,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> { list.push(nm); } }- n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") || n == "Version" => {}+ "Version" => escaped_fonts = value.trim().parse::<u32>().is_ok_and(|v| v >= ESCAPED_FONTS_VERSION),+ n if n.starts_with("ASCII-") || n.starts_with("UNICODE-") || n.starts_with("ANSI-") => {} _ => skipped += 1, } }@@ -338,6 +349,31 @@ mod tests { } } + #[test]+ fn font_values_with_tag_characters_round_trip() {+ let doc = Document::new(&designcraft_doc::build::NewDocument::default());+ for name in ["Weird>Font", "A<b>c", "x\\y", "end\\"] {+ let mut story = Story::new(StoryId(0));+ let mut f = CharFormat::default();+ f.over.font_family = Some(name.into());+ f.over.font_style = Some(name.into());+ story.insert_with(0, "hello", f);+ let out = tagged_text(&doc, &story);+ let back = import(out.as_bytes()).unwrap();+ assert_eq!(back.story.text, "hello", "{out}");+ assert_eq!(back.story.char_format_at(1).over.font_family.as_deref(), Some(name), "{out}");+ assert_eq!(back.story.char_format_at(1).over.font_style.as_deref(), Some(name), "{out}");+ }+ }++ /// Version 7 files hold font values raw, so their backslashes stay literal.+ #[test]+ fn old_format_font_values_stay_literal() {+ let i = import("<ASCII-WIN>\r\n<Version:7>\r\n<ParaStyle:Body><cFont:a\\b>x<cFont:><cTypeface:c\\d>y".as_bytes()).unwrap();+ assert_eq!(i.story.char_format_at(1).over.font_family.as_deref(), Some("a\\b"));+ assert_eq!(i.story.char_format_at(2).over.font_style.as_deref(), Some("c\\d"));+ }+ /// A backslash that isn't one of our escapes (older files, InDesign's `\:`) stays literal. #[test] fn unknown_backslashes_in_style_names_stay_literal() {