collective

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

diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs--- a/crates/textimport/src/tagged.rs+++ b/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() {