Browse / github.com/storytold/filmcraft / patch
Preserve Unicode names in graphics template export identities
Preserve Unicode names in graphics template export identities Template names containing only non-ASCII letters currently collapse to `template`, so exporting names such as `片头` and `片尾` writes the same file and library ID. Preserve Unicode alphanumeric characters and their lowercase spelling in the shared slug helper, while retaining existing punctuation separation and ASCII behavior. This addresses the non-ASCII-name subcase of #3237; case, punctuation and Unicode normalization collisions retain the existing slug semantics. Validation: the actual engine export regression fails on the original helper because both exports use `template.fcgt`; it passes
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- jnohclee-rgb (imported from pull request #3245, open)
- Base
0f9966189daf46dcdc4bcb1fb73441bac80076be- Size
- +32 −3 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1112aa371f81b21f5feb77c5
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/graphic_templates_tests.rs crates/project/src/gtemplate.rs
@@ -40,6 +40,25 @@ fn clip_of(v: &Value) -> ClipId { ClipId(v["clip"].as_u64().unwrap()) } +#[test]+fn template_exports_keep_distinct_non_ascii_names() {+ let mut s = demo("gt-unicode-export");+ let c = clip_of(&s.execute("graphics.newText", json!({"text": "Headline"})).unwrap());+ let first = s.execute("graphics.template.export", json!({"clip": c.0, "name": "片头"})).unwrap();+ let first_path = first["path"].as_str().unwrap();+ let first_bytes = std::fs::read(first_path).unwrap();+ let second = s.execute("graphics.template.export", json!({"clip": c.0, "name": "片尾"})).unwrap();+ assert_ne!(first["path"], second["path"], "distinct names must not overwrite the first export");+ assert_ne!(first["id"], second["id"], "distinct templates need distinct library identities");+ assert_eq!(std::fs::read(first_path).unwrap(), first_bytes);+ let second_file: Value = serde_json::from_slice(&std::fs::read(second["path"].as_str().unwrap()).unwrap()).unwrap();+ assert_eq!(second_file["name"], "片尾");+ let installed = s.execute("graphics.template.list", json!({"source": "user"})).unwrap();+ for exported in [&first, &second] {+ assert!(installed.as_array().unwrap().iter().any(|entry| entry["id"] == exported["id"] && entry["name"] == exported["name"]));+ }+}+ #[test] fn template_export_install_apply_round_trip() { let mut s = demo("gt-export");@@ -223,12 +223,12 @@ pub enum TemplateError { Invalid(String), } -/// Lower-case slug of a name (`My Lower Third` → `my-lower-third`).+/// Lower-case slug of a name (`My Lower Third` → `my-lower-third`), retaining Unicode letters and digits. pub fn slug(name: &str) -> String { let mut s = String::new(); for c in name.chars() {- if c.is_ascii_alphanumeric() {- s.push(c.to_ascii_lowercase());+ if c.is_alphanumeric() {+ s.extend(c.to_lowercase()); } else if !s.ends_with('-') && !s.is_empty() { s.push('-'); }@@ -744,6 +744,16 @@ mod tests { assert!(matches!(t.validate(), Err(TemplateError::Invalid(_)))); } + #[test]+ fn template_slugs_preserve_non_ascii_names() {+ assert_eq!(slug("片头"), "片头");+ assert_eq!(slug("片尾"), "片尾");+ assert_eq!(slug("Écran Été"), "écran-été");+ assert_eq!(slug("My Lower Third!"), "my-lower-third");+ assert_eq!(slug(" ../片头\\片尾! "), "片头-片尾");+ assert_eq!(slug("---"), "template");+ }+ #[test] fn instantiates_on_other_frame_sizes() { let t = builtin_templates().into_iter().find(|t| t.id == "builtin:title-centered").unwrap();