diff --git a/Cargo.lock b/Cargo.lock index 81183e5..1b4c371 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -126,6 +126,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -1302,6 +1308,7 @@ name = "msg_tool" version = "0.2.4" dependencies = [ "anyhow", + "base64", "byteorder", "clap 4.5.47", "csv", diff --git a/Cargo.toml b/Cargo.toml index d63a62b..d9dc081 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ exclude = [".github", "*.py"] [dependencies] anyhow = "1" +base64 = { version = "0.22", optional = true } byteorder = { version = "1.5", default-features = false, optional = true} clap = { version = "4.5", features = ["derive"] } csv = "1.3" @@ -68,7 +69,7 @@ circus = [] circus-arc = ["circus"] circus-audio = ["circus", "flate2", "int-enum", "lossless-audio"] circus-img = ["circus", "image", "flate2", "zstd"] -emote-img = ["emote-psb", "image", "libtlg-rs", "url"] +emote-img = ["base64", "emote-psb", "image", "libtlg-rs", "url"] entis-gls = ["xml5ever", "markup5ever", "markup5ever_rcdom"] escude = ["int-enum"] escude-arc = ["escude", "rand", "utils-bit-stream"] diff --git a/README.md b/README.md index de15d28..d1cb597 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ msg-tool create -t | `circus-crx` | `circus-img` | Circus Image File (.crx) | ✔️ | ✔️ | ❌ | ❌ | ✔️ | | | `circus-crxd` | `circus-img` | Circus Differential Image File (.crx) | ✔️ | ❌ | ❌ | ❌ | ❌ | | ### Emote +| Script Type | Feature Name | Name | Export | Import | Custom Export | Custom Import | Create | Remarks | +|---|---|---|---|---|---|---|---|---| +| `emote-psb`/`psb` | `emote-psb` | Emote PSB File | ❌ | ❌ | ✔️ | ❌| ❌ | | + | Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | |---|---|---|---|---|---|---|---|---| | `emote-pimg`/`pimg` | `emote-img` | Emote Multiple Image File (.pimg) | ❌ | ❌ | ✔️ | ❌ | ❌ | | diff --git a/src/args.rs b/src/args.rs index 2f538ae..23f1d24 100644 --- a/src/args.rs +++ b/src/args.rs @@ -464,6 +464,10 @@ pub struct Arg { #[arg(long, global = true)] /// Path to custom jieba dictionary pub jieba_dict: Option, + #[cfg(feature = "emote-img")] + #[arg(long, global = true, action = ArgAction::SetTrue, visible_alias = "psb-no-tlg")] + /// Do not process TLG images in PSB files. + pub psb_no_process_tlg: bool, #[command(subcommand)] /// Command pub command: Command, diff --git a/src/ext/psb.rs b/src/ext/psb.rs index e7ffa40..da592fd 100644 --- a/src/ext/psb.rs +++ b/src/ext/psb.rs @@ -145,6 +145,24 @@ impl PsbValueFixed { matches!(self, PsbValueFixed::Null) } + /// Find the resource's key in object + pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + match self { + PsbValueFixed::List(l) => l.find_resource_key(resource_id), + PsbValueFixed::Object(o) => o.find_resource_key(resource_id), + _ => 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> { + match self { + PsbValueFixed::List(l) => l.find_extra_resource_key(extra_resource_id), + PsbValueFixed::Object(o) => o.find_extra_resource_key(extra_resource_id), + _ => None, + } + } + /// Sets the value of this PSB value to a new string. pub fn set_str(&mut self, value: &str) { match self { @@ -256,6 +274,14 @@ impl PsbValueFixed { } } + /// Returns the extra resource ID if this value is an extra resource reference. + pub fn extra_resource_id(&self) -> Option { + match self { + PsbValueFixed::ExtraResource(er) => Some(er.extra_resource_ref), + _ => None, + } + } + /// Converts this value to a JSON value, if possible. #[cfg(feature = "json")] pub fn to_json(&self) -> Option { @@ -269,10 +295,10 @@ impl PsbValueFixed { }, PsbValueFixed::String(s) => Some(JsonValue::String(s.string().to_owned())), PsbValueFixed::Resource(s) => { - Some(JsonValue::String(format!("resource#{}", s.resource_ref))) + Some(JsonValue::String(format!("#resource#{}", s.resource_ref))) } PsbValueFixed::ExtraResource(s) => Some(JsonValue::String(format!( - "extra_resource#{}", + "#resource@{}", s.extra_resource_ref ))), PsbValueFixed::IntArray(arr) => Some(JsonValue::Array( @@ -299,12 +325,12 @@ impl PsbValueFixed { } } JsonValue::String(s) => { - if s.starts_with("resource#") { - if let Ok(id) = s[9..].parse::() { + if s.starts_with("#resource#") { + if let Ok(id) = s[10..].parse::() { return PsbValueFixed::Resource(PsbResourceRef { resource_ref: id }); } - } else if s.starts_with("extra_resource#") { - if let Ok(id) = s[16..].parse::() { + } else if s.starts_with("#resource@") { + if let Ok(id) = s[10..].parse::() { return PsbValueFixed::ExtraResource(PsbExtraRef { extra_resource_ref: id, }); @@ -325,12 +351,12 @@ impl PsbValueFixed { } JsonValue::Short(n) => { let s = n.as_str(); - if s.starts_with("resource#") { - if let Ok(id) = s[9..].parse::() { + if s.starts_with("#resource#") { + if let Ok(id) = s[10..].parse::() { return PsbValueFixed::Resource(PsbResourceRef { resource_ref: id }); } - } else if s.starts_with("extra_resource#") { - if let Ok(id) = s[16..].parse::() { + } else if s.starts_with("#resource@") { + if let Ok(id) = s[10..].parse::() { return PsbValueFixed::ExtraResource(PsbExtraRef { extra_resource_ref: id, }); @@ -519,6 +545,26 @@ impl PsbListFixed { PsbList::from(v) } + /// Find the resource's key in object + pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + for value in &self.values { + if let Some(key) = value.find_resource_key(resource_id) { + return Some(key); + } + } + 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> { + for value in &self.values { + if let Some(key) = value.find_extra_resource_key(extra_resource_id) { + return Some(key); + } + } + None + } + /// Returns a iterator over the values in the list. pub fn iter(&self) -> ListIter<'_> { ListIter { @@ -682,6 +728,36 @@ impl PsbObjectFixed { self.values.get(key) } + /// Find the resource's key in object + pub fn find_resource_key<'a>(&'a self, resource_id: u64) -> Option<&'a str> { + for (key, value) in &self.values { + if let Some(id) = value.resource_id() { + if id == resource_id { + return Some(key); + } + } + if let Some(key) = value.find_resource_key(resource_id) { + return Some(key); + } + } + 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> { + for (key, value) in &self.values { + if let Some(id) = value.extra_resource_id() { + if id == extra_resource_id { + return Some(key); + } + } + if let Some(key) = value.find_extra_resource_key(extra_resource_id) { + return Some(key); + } + } + None + } + /// Returns a iterator over the entries of the object. pub fn iter(&self) -> ObjectIter<'_> { ObjectIter { diff --git a/src/main.rs b/src/main.rs index 2b9f3cb..bb73842 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2056,6 +2056,8 @@ fn main() { jxl_distance: arg.jxl_distance, #[cfg(feature = "image-jxl")] jxl_workers: arg.jxl_workers, + #[cfg(feature = "emote-img")] + psb_process_tlg: !arg.psb_no_process_tlg, }); match &arg.command { args::Command::Export { input, output } => { diff --git a/src/scripts/emote/mod.rs b/src/scripts/emote/mod.rs index c4c0c3d..1ba6e53 100644 --- a/src/scripts/emote/mod.rs +++ b/src/scripts/emote/mod.rs @@ -1,3 +1,4 @@ //! Emote images pub mod dref; pub mod pimg; +pub mod psb; diff --git a/src/scripts/emote/psb.rs b/src/scripts/emote/psb.rs new file mode 100644 index 0000000..c3ea13f --- /dev/null +++ b/src/scripts/emote/psb.rs @@ -0,0 +1,242 @@ +//! Basic Handle for all emote PSB files. +use crate::ext::io::*; +use crate::ext::psb::*; +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::*; +use crate::utils::img::*; +use anyhow::Result; +use base64::Engine; +use emote_psb::*; +use libtlg_rs::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{Read, Seek, Write}; + +#[derive(Debug)] +pub struct PsbBuilder {} + +impl PsbBuilder { + pub fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for PsbBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Utf8 + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(Psb::new(MemReader::new(buf), encoding, config)?)) + } + + fn build_script_from_reader( + &self, + reader: Box, + _filename: &str, + encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(Psb::new(reader, encoding, config)?)) + } + + fn build_script_from_file( + &self, + filename: &str, + encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + let file = std::fs::File::open(filename)?; + let f = std::io::BufReader::new(file); + Ok(Box::new(Psb::new(f, encoding, config)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &[] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::EmotePsb + } + + fn is_this_format(&self, _filename: &str, buf: &[u8], buf_len: usize) -> Option { + if buf_len >= 4 && buf.starts_with(b"PSB\0") { + return Some(10); + } + None + } +} + +#[derive(Debug)] +pub struct Psb { + psb: VirtualPsbFixed, + encoding: Encoding, + config: ExtraConfig, +} + +impl Psb { + pub fn new( + reader: R, + encoding: Encoding, + config: &ExtraConfig, + ) -> Result { + let mut psb = PsbReader::open_psb(reader) + .map_err(|e| anyhow::anyhow!("Failed to open psb file: {:?}", e))?; + let psb = psb + .load() + .map_err(|e| anyhow::anyhow!("Failed to load psb: {:?}", e))? + .to_psb_fixed(); + Ok(Self { + psb, + encoding, + config: config.clone(), + }) + } + + fn output_resource( + &self, + folder_path: &std::path::PathBuf, + path: String, + data: &[u8], + ) -> Result { + let mut res = Resource { path, tlg: None }; + if self.config.psb_process_tlg && is_valid_tlg(&data) { + let tlg = load_tlg(MemReaderRef::new(&data))?; + res.tlg = Some(TlgInfo::from_tlg(&tlg, self.encoding)); + let outtype = self.config.image_type.unwrap_or(ImageOutputType::Png); + res.path = { + let mut pb = std::path::PathBuf::from(&res.path); + pb.set_extension(outtype.as_ref()); + pb.to_string_lossy().to_string() + }; + let path = folder_path.join(&res.path); + let img = ImageData { + width: tlg.width as u32, + height: tlg.height as u32, + color_type: match tlg.color { + TlgColorType::Bgr24 => ImageColorType::Bgr, + TlgColorType::Bgra32 => ImageColorType::Bgra, + TlgColorType::Grayscale8 => ImageColorType::Grayscale, + }, + depth: 8, + data: tlg.data, + }; + encode_img(img, outtype, &path.to_string_lossy(), &self.config)?; + } else { + let path = folder_path.join(&res.path); + std::fs::write(&path, data)?; + } + Ok(res) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct TlgInfo { + metadata: HashMap, +} + +impl TlgInfo { + fn from_tlg(tlg: &Tlg, encoding: Encoding) -> Self { + let mut metadata = HashMap::new(); + for (k, v) in &tlg.tags { + let k = if let Ok(s) = decode_to_string(encoding, &k, true) { + s + } else { + format!( + "base64:{}", + base64::engine::general_purpose::STANDARD.encode(k) + ) + }; + let v = if let Ok(s) = decode_to_string(encoding, &v, true) { + s + } else { + format!( + "base64:{}", + base64::engine::general_purpose::STANDARD.encode(v) + ) + }; + metadata.insert(k, v); + } + Self { metadata } + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Resource { + path: String, + tlg: Option, +} + +impl Script for Psb { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Custom + } + + fn is_output_supported(&self, output: OutputScriptType) -> bool { + matches!(output, OutputScriptType::Custom) + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn custom_output_extension<'a>(&'a self) -> &'a str { + "json" + } + + fn custom_export(&self, filename: &std::path::Path, encoding: Encoding) -> Result<()> { + let mut data = self.psb.to_json(); + let mut resources = Vec::new(); + let mut extra_resources = Vec::new(); + let folder_path = { + let mut pb = filename.to_path_buf(); + 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()) + .unwrap_or(format!("res_{}", i)); + let res = self.output_resource(&folder_path, res_name, data)?; + resources.push(res); + } + for (i, data) in self.psb.extra().iter().enumerate() { + let i = i as u64; + let res_name = self + .psb + .root() + .find_resource_key(i) + .map(|s| format!("extra_{}", s)) + .unwrap_or(format!("extra_res_{}", i)); + let res = self.output_resource(&folder_path, res_name, data)?; + extra_resources.push(res); + } + data["resources"] = json::parse(&serde_json::to_string(&resources)?)?; + data["extra_resources"] = json::parse(&serde_json::to_string(&extra_resources)?)?; + let s = json::stringify_pretty(data, 2); + let s = encode_string(encoding, &s, false)?; + let mut file = std::fs::File::create(filename)?; + file.write_all(&s)?; + Ok(()) + } +} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 0d8f224..0a68d35 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -128,6 +128,8 @@ lazy_static::lazy_static! { Box::new(favorite::hcb::HcbScriptBuilder::new()), #[cfg(feature = "silky")] Box::new(silky::map::MapBuilder::new()), + #[cfg(feature = "emote-img")] + Box::new(emote::psb::PsbBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/types.rs b/src/types.rs index 31ac597..393458d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -445,6 +445,10 @@ pub struct ExtraConfig { /// Workers count for encode JXL images in parallel. Default is 1. /// Set this to 1 to disable parallel encoding. 0 means same as 1 pub jxl_workers: usize, + #[cfg(feature = "emote-img")] + #[default(true)] + /// Process tlg images. + pub psb_process_tlg: bool, } #[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq, PartialOrd, Ord)] @@ -533,6 +537,10 @@ pub enum ScriptType { /// Circus Differential Image CircusCrxd, #[cfg(feature = "emote-img")] + #[value(alias("psb"))] + /// Emote PSB (basic handle) + EmotePsb, + #[cfg(feature = "emote-img")] #[value(alias("pimg"))] /// Emote PIMG image EmotePimg,