mirror of
https://github.com/lifegpc/msg-tool.git
synced 2026-06-18 17:04:50 +08:00
add import support for emote psb files
fix create file bug in main
This commit is contained in:
@@ -130,7 +130,7 @@ msg-tool create -t <script-type> <input> <output>
|
||||
### 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 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
@@ -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<Vec<&'a str>> {
|
||||
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<Vec<&'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),
|
||||
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<Vec<&'a str>> {
|
||||
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<Vec<&'a str>> {
|
||||
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<Vec<&'a str>> {
|
||||
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<Vec<&'a str>> {
|
||||
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<Self, anyhow::Error> {
|
||||
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;
|
||||
|
||||
@@ -1876,6 +1876,8 @@ pub fn create_file(
|
||||
}
|
||||
};
|
||||
|
||||
crate::utils::files::make_sure_dir_exists(&output)?;
|
||||
|
||||
builder.create_file_filename(
|
||||
input,
|
||||
&output,
|
||||
|
||||
@@ -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<dyn WriteSeek + 'a>,
|
||||
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<HashMap<Vec<u8>, Vec<u8>>> {
|
||||
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<TlgInfo>,
|
||||
}
|
||||
|
||||
@@ -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<dyn WriteSeek + 'a>,
|
||||
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<Vec<u8>> {
|
||||
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<dyn WriteSeek + 'a>,
|
||||
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<Resource> = serde_json::from_str(&data["resources"].dump())?;
|
||||
let extra_resources: Vec<Resource> = 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(())
|
||||
}
|
||||
|
||||
@@ -241,3 +241,98 @@ pub fn make_sure_dir_exists<F: AsRef<Path> + ?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<String> = {
|
||||
let mut v = vec!["CON", "PRN", "AUX", "NUL"]
|
||||
.into_iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user