From ec137bc5cddab03e2f5615239393a535572356ee Mon Sep 17 00:00:00 2001 From: lifegpc Date: Mon, 15 Sep 2025 23:14:19 +0800 Subject: [PATCH] add import support for emote psb files fix create file bug in main --- README.md | 2 +- src/ext/psb.rs | 81 ++++++++++++++++++----- src/main.rs | 2 + src/scripts/emote/psb.rs | 137 +++++++++++++++++++++++++++++++++++++-- src/utils/files.rs | 95 +++++++++++++++++++++++++++ 5 files changed, 293 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d1cb597..803f00b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ msg-tool create -t ### Emote | Script Type | Feature Name | Name | Export | Import | Custom Export | Custom Import | Create | Remarks | |---|---|---|---|---|---|---|---|---| -| `emote-psb`/`psb` | `emote-psb` | Emote PSB File | ❌ | ❌ | ✔️ | ❌| ❌ | | +| `emote-psb`/`psb` | `emote-psb` | Emote PSB File | ❌ | ❌ | ✔️ | ✔️ | ✔️ | | | Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | |---|---|---|---|---|---|---|---|---| diff --git a/src/ext/psb.rs b/src/ext/psb.rs index da592fd..187ca35 100644 --- a/src/ext/psb.rs +++ b/src/ext/psb.rs @@ -146,19 +146,27 @@ impl PsbValueFixed { } /// Find the resource's key in object - pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + pub fn find_resource_key<'a>( + &'a self, + resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { match self { - PsbValueFixed::List(l) => l.find_resource_key(resource_id), - PsbValueFixed::Object(o) => o.find_resource_key(resource_id), + PsbValueFixed::List(l) => l.find_resource_key(resource_id, now), + PsbValueFixed::Object(o) => o.find_resource_key(resource_id, now), _ => None, } } /// Find the extra resource's key in object - pub fn find_extra_resource_key<'a>(&'a self, extra_resource_id: u64) -> Option<&'a str> { + pub fn find_extra_resource_key<'a>( + &'a self, + extra_resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { match self { - PsbValueFixed::List(l) => l.find_extra_resource_key(extra_resource_id), - PsbValueFixed::Object(o) => o.find_extra_resource_key(extra_resource_id), + PsbValueFixed::List(l) => l.find_extra_resource_key(extra_resource_id, now), + PsbValueFixed::Object(o) => o.find_extra_resource_key(extra_resource_id, now), _ => None, } } @@ -546,9 +554,13 @@ impl PsbListFixed { } /// Find the resource's key in object - pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + pub fn find_resource_key<'a>( + &'a self, + resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { for value in &self.values { - if let Some(key) = value.find_resource_key(resource_id) { + if let Some(key) = value.find_resource_key(resource_id, now.clone()) { return Some(key); } } @@ -556,9 +568,13 @@ impl PsbListFixed { } /// Find the extra resource's key in object - pub fn find_extra_resource_key<'a>(&'a self, extra_resource_id: u64) -> Option<&'a str> { + pub fn find_extra_resource_key<'a>( + &'a self, + extra_resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { for value in &self.values { - if let Some(key) = value.find_extra_resource_key(extra_resource_id) { + if let Some(key) = value.find_extra_resource_key(extra_resource_id, now.clone()) { return Some(key); } } @@ -729,14 +745,20 @@ impl PsbObjectFixed { } /// Find the resource's key in object - pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + pub fn find_resource_key<'a>( + &'a self, + resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { for (key, value) in &self.values { + let mut now = now.clone(); + now.push(key); if let Some(id) = value.resource_id() { if id == resource_id { - return Some(key); + return Some(now); } } - if let Some(key) = value.find_resource_key(resource_id) { + if let Some(key) = value.find_resource_key(resource_id, now) { return Some(key); } } @@ -744,14 +766,20 @@ impl PsbObjectFixed { } /// Find the extra resource's key in object - pub fn find_extra_resource_key<'a>(&'a self, extra_resource_id: u64) -> Option<&'a str> { + pub fn find_extra_resource_key<'a>( + &'a self, + extra_resource_id: u64, + now: Vec<&'a str>, + ) -> Option> { for (key, value) in &self.values { + let mut now = now.clone(); + now.push(key); if let Some(id) = value.extra_resource_id() { if id == extra_resource_id { - return Some(key); + return Some(now); } } - if let Some(key) = value.find_extra_resource_key(extra_resource_id) { + if let Some(key) = value.find_extra_resource_key(extra_resource_id, now) { return Some(key); } } @@ -1021,6 +1049,27 @@ impl VirtualPsbFixed { Ok(()) } + #[cfg(feature = "json")] + /// Creates a fixed PSB from a JSON object. + pub fn with_json(obj: &JsonValue) -> Result { + let version = obj["version"] + .as_u16() + .ok_or_else(|| anyhow::anyhow!("Invalid PSB version"))?; + let encryption = obj["encryption"] + .as_u16() + .ok_or_else(|| anyhow::anyhow!("Invalid PSB encryption"))?; + let root = PsbObjectFixed::from_json(&obj["data"]); + Ok(Self { + header: PsbHeader { + version, + encryption, + }, + resources: Vec::new(), + extra: Vec::new(), + root, + }) + } + pub fn set_data(&mut self, data: VirtualPsbFixedData) { self.header.version = data.version; self.header.encryption = data.encryption; diff --git a/src/main.rs b/src/main.rs index bb73842..3607da1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1876,6 +1876,8 @@ pub fn create_file( } }; + crate::utils::files::make_sure_dir_exists(&output)?; + builder.create_file_filename( input, &output, diff --git a/src/scripts/emote/psb.rs b/src/scripts/emote/psb.rs index c3ea13f..b067e53 100644 --- a/src/scripts/emote/psb.rs +++ b/src/scripts/emote/psb.rs @@ -4,6 +4,7 @@ use crate::ext::psb::*; use crate::scripts::base::*; use crate::types::*; use crate::utils::encoding::*; +use crate::utils::files::*; use crate::utils::img::*; use anyhow::Result; use base64::Engine; @@ -78,6 +79,21 @@ impl ScriptBuilder for PsbBuilder { } None } + + fn can_create_file(&self) -> bool { + true + } + + fn create_file<'a>( + &'a self, + filename: &'a str, + writer: Box, + encoding: Encoding, + file_encoding: Encoding, + _config: &ExtraConfig, + ) -> Result<()> { + create_file(filename, writer, encoding, file_encoding) + } } #[derive(Debug)] @@ -123,6 +139,7 @@ impl Psb { pb.to_string_lossy().to_string() }; let path = folder_path.join(&res.path); + make_sure_dir_exists(&path)?; let img = ImageData { width: tlg.width as u32, height: tlg.height as u32, @@ -137,6 +154,7 @@ impl Psb { encode_img(img, outtype, &path.to_string_lossy(), &self.config)?; } else { let path = folder_path.join(&res.path); + make_sure_dir_exists(&path)?; std::fs::write(&path, data)?; } Ok(res) @@ -172,11 +190,30 @@ impl TlgInfo { } Self { metadata } } + + fn to_tlg_tags(&self, encoding: Encoding) -> Result, Vec>> { + let mut tags = HashMap::new(); + for (k, v) in &self.metadata { + let k = if k.starts_with("base64:") { + base64::engine::general_purpose::STANDARD.decode(&k[7..])? + } else { + encode_string(encoding, k, false)? + }; + let v = if v.starts_with("base64:") { + base64::engine::general_purpose::STANDARD.decode(&v[7..])? + } else { + encode_string(encoding, v, false)? + }; + tags.insert(k, v); + } + Ok(tags) + } } #[derive(Debug, Deserialize, Serialize)] struct Resource { path: String, + #[serde(skip_serializing_if = "Option::is_none")] tlg: Option, } @@ -206,17 +243,15 @@ impl Script for Psb { pb.set_extension(""); pb }; - if self.psb.resources().len() > 0 || self.psb.extra().len() > 0 { - std::fs::create_dir_all(&folder_path)?; - } for (i, data) in self.psb.resources().iter().enumerate() { let i = i as u64; let res_name = self .psb .root() - .find_resource_key(i) - .map(|s| s.to_string()) + .find_resource_key(i, vec![]) + .map(|s| s.join("/")) .unwrap_or(format!("res_{}", i)); + let res_name = sanitize_path(&res_name); let res = self.output_resource(&folder_path, res_name, data)?; resources.push(res); } @@ -225,9 +260,10 @@ impl Script for Psb { let res_name = self .psb .root() - .find_resource_key(i) - .map(|s| format!("extra_{}", s)) + .find_resource_key(i, vec![]) + .map(|s| format!("extra_{}", s.join("/"))) .unwrap_or(format!("extra_res_{}", i)); + let res_name = sanitize_path(&res_name); let res = self.output_resource(&folder_path, res_name, data)?; extra_resources.push(res); } @@ -239,4 +275,91 @@ impl Script for Psb { file.write_all(&s)?; Ok(()) } + + fn custom_import<'a>( + &'a self, + custom_filename: &'a str, + file: Box, + encoding: Encoding, + output_encoding: Encoding, + ) -> Result<()> { + create_file(custom_filename, file, encoding, output_encoding) + } +} + +fn read_resource( + folder_path: &std::path::PathBuf, + res: &Resource, + encoding: Encoding, +) -> Result> { + if let Some(tlg) = &res.tlg { + let path = folder_path.join(&res.path); + let imgfmt = ImageOutputType::try_from(path.as_path())?; + let mut img = decode_img(imgfmt, &path.to_string_lossy())?; + if img.depth != 8 { + return Err(anyhow::anyhow!( + "Only 8-bit images are supported for TLG conversion" + )); + } + let color_type = match img.color_type { + ImageColorType::Bgr => TlgColorType::Bgr24, + ImageColorType::Bgra => TlgColorType::Bgra32, + ImageColorType::Grayscale => TlgColorType::Grayscale8, + ImageColorType::Rgb => { + convert_rgb_to_bgr(&mut img)?; + TlgColorType::Bgr24 + } + ImageColorType::Rgba => { + convert_rgba_to_bgra(&mut img)?; + TlgColorType::Bgra32 + } + }; + let tlg = Tlg { + width: img.width, + height: img.height, + version: 5, + color: color_type, + data: img.data, + tags: tlg.to_tlg_tags(encoding)?, + }; + let mut writer = MemWriter::new(); + save_tlg(&tlg, &mut writer)?; + Ok(writer.into_inner()) + } else { + let path = folder_path.join(&res.path); + Ok(std::fs::read(&path)?) + } +} + +fn create_file<'a>( + custom_filename: &'a str, + mut writer: Box, + encoding: Encoding, + output_encoding: Encoding, +) -> Result<()> { + let input = read_file(custom_filename)?; + let s = decode_to_string(output_encoding, &input, true)?; + let data = json::parse(&s)?; + let resources: Vec = serde_json::from_str(&data["resources"].dump())?; + let extra_resources: Vec = serde_json::from_str(&data["extra_resources"].dump())?; + let mut psb = VirtualPsbFixed::with_json(&data)?; + let folder_path = { + let mut pb = std::path::PathBuf::from(custom_filename); + pb.set_extension(""); + pb + }; + for res in resources { + let res = read_resource(&folder_path, &res, encoding)?; + psb.resources_mut().push(res); + } + for res in extra_resources { + let res = read_resource(&folder_path, &res, encoding)?; + psb.extra_mut().push(res); + } + let psb = psb.to_psb(false); + let psb_writer = PsbWriter::new(psb, &mut writer); + psb_writer + .finish() + .map_err(|e| anyhow::anyhow!("Failed to write psb: {:?}", e))?; + Ok(()) } diff --git a/src/utils/files.rs b/src/utils/files.rs index 756215a..adf48a2 100644 --- a/src/utils/files.rs +++ b/src/utils/files.rs @@ -241,3 +241,98 @@ pub fn make_sure_dir_exists + ?Sized>(f: &F) -> io::Result<()> { } Ok(()) } + +/// Replace symbols not allowed in Windows path with underscores. +pub fn sanitize_path(path: &str) -> String { + // Split path into components, preserving separators + if path.is_empty() { + return String::new(); + } + + let invalid_chars: &[char] = &['<', '>', '"', '|', '?', '*']; + let mut result = String::with_capacity(path.len()); + + let reserved_names: Vec = { + let mut v = vec!["CON", "PRN", "AUX", "NUL"] + .into_iter() + .map(|s| s.to_string()) + .collect::>(); + for i in 1..=9 { + v.push(format!("COM{}", i)); + v.push(format!("LPT{}", i)); + } + v + }; + + let bytes = path.as_bytes(); + let len = bytes.len(); + let mut start = 0usize; + + while start < len { + // find next separator index + let mut end = start; + while end < len && bytes[end] != b'\\' && bytes[end] != b'/' { + end += 1; + } + // segment is path[start..end] + let seg = &path[start..end]; + + // sanitize segment + let mut s = String::with_capacity(seg.len()); + for (i, ch) in seg.chars().enumerate() { + // allow drive letter colon like "C:" (i == 1, first char is ASCII letter) + if ch == ':' { + if i == 1 { + // check first char is ASCII letter + if seg + .chars() + .next() + .map(|c| c.is_ascii_alphabetic()) + .unwrap_or(false) + { + s.push(':'); + continue; + } + } + // otherwise treat as invalid + s.push('_'); + continue; + } + // keep separators out of segment (shouldn't appear here) + // replace control chars and other invalids + if (ch as u32) < 32 || invalid_chars.contains(&ch) { + s.push('_'); + } else { + s.push(ch); + } + } + + // trim trailing spaces and dots (Windows disallows filenames ending with space or dot) + while s.ends_with(' ') || s.ends_with('.') { + s.pop(); + } + + if s.is_empty() { + s.push('_'); + } else { + // check reserved names (base name before first '.') + let base = s.split('.').next().unwrap_or("").to_ascii_uppercase(); + if reserved_names.iter().any(|r| r == &base) { + s = format!("_{}", s); + } + } + + result.push_str(&s); + + // append separator if present + if end < len { + // keep original separator (preserve '\' or '/') + result.push(path.as_bytes()[end] as char); + start = end + 1; + } else { + start = end; + } + } + + result +}