From 17fbc10d2e88ad0f68aec1f00ee8d4d1f75c1d78 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sun, 17 May 2026 15:54:41 +0800 Subject: [PATCH] Add support for Yu-Ris scenario text file (.txt) --- src/scripts/mod.rs | 2 + src/scripts/yuris/mod.rs | 1 + src/scripts/yuris/txt.rs | 757 +++++++++++++++++++++++++++++++++++++++ src/types.rs | 3 + 4 files changed, 763 insertions(+) create mode 100644 src/scripts/yuris/txt.rs diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 4116d51..814042d 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -184,6 +184,8 @@ lazy_static::lazy_static! { Box::new(yuris::yscfg::YSCFGBuilder::new()), #[cfg(feature = "yuris")] Box::new(yuris::ystb::YSTBBuilder::new()), + #[cfg(feature = "yuris")] + Box::new(yuris::txt::YurisTxtBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/scripts/yuris/mod.rs b/src/scripts/yuris/mod.rs index e0cbc56..3bb9b29 100644 --- a/src/scripts/yuris/mod.rs +++ b/src/scripts/yuris/mod.rs @@ -1,4 +1,5 @@ //! Yu-Ris Engine Scripts +pub mod txt; mod types; pub mod yscfg; pub mod yscm; diff --git a/src/scripts/yuris/txt.rs b/src/scripts/yuris/txt.rs new file mode 100644 index 0000000..f40a5eb --- /dev/null +++ b/src/scripts/yuris/txt.rs @@ -0,0 +1,757 @@ +//! Yu-Ris scenario text file (.txt) +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::encoding::*; +use anyhow::Result; +use std::ops::{Deref, DerefMut}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug)] +pub struct YurisTxtBuilder {} + +impl YurisTxtBuilder { + /// Creates a new instance of `YurisTxtBuilder` + pub const fn new() -> Self { + YurisTxtBuilder {} + } +} + +impl ScriptBuilder for YurisTxtBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Cp932 + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + encoding: Encoding, + _archive_encoding: Encoding, + _config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(YurisTxt::new(&buf, encoding)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["txt"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::YurisTxt + } +} + +trait INode { + fn serialize(&self) -> String; +} + +#[derive(Clone, Debug, PartialEq)] +struct CommentNode(String); + +impl Deref for CommentNode { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CommentNode { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl INode for CommentNode { + fn serialize(&self) -> String { + format!("//{}", self.0) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct LabelNode(String); + +impl Deref for LabelNode { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for LabelNode { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl INode for LabelNode { + fn serialize(&self) -> String { + format!("#{}", self.0) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct CommandNode { + name: String, + args: Vec, + has_args: bool, +} + +impl INode for CommandNode { + fn serialize(&self) -> String { + if !self.has_args { + return format!("\\{}", self.name); + } + let mut s = format!("\\{}(", self.name); + let mut first = true; + for arg in &self.args { + if first { + first = false; + } else { + s.push_str(", "); + } + if arg.contains(" ") || arg.contains(",") { + s.push_str(&format!("\"{}\"", arg)); + } else { + s.push_str(arg); + } + } + s.push(')'); + s + } +} + +#[derive(Clone, Debug, PartialEq)] +struct NameNode(String); + +impl Deref for NameNode { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for NameNode { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl INode for NameNode { + fn serialize(&self) -> String { + format!("【{}】", self.0) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct TextNode(String); + +impl Deref for TextNode { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for TextNode { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl INode for TextNode { + fn serialize(&self) -> String { + self.0.clone() + } +} + +#[derive(Clone, Debug, PartialEq)] +struct CommentBlock(String); + +impl Deref for CommentBlock { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CommentBlock { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl INode for CommentBlock { + fn serialize(&self) -> String { + format!("/*{}*/", self.0) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum LineNode { + Comment(CommentNode), + Comments(CommentBlock), + Command(CommandNode), + Name(NameNode), + Text(TextNode), +} + +impl INode for LineNode { + fn serialize(&self) -> String { + match self { + Self::Comment(node) => node.serialize(), + Self::Comments(node) => node.serialize(), + Self::Command(node) => node.serialize(), + Self::Name(node) => node.serialize(), + Self::Text(node) => node.serialize(), + } + } +} + +impl INode for Vec { + fn serialize(&self) -> String { + self.iter() + .map(|s| s.serialize()) + .collect::>() + .join("") + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Line { + Line(Vec), + Empty, + Label(LabelNode), +} + +impl INode for Line { + fn serialize(&self) -> String { + match self { + Self::Line(line) => line.serialize(), + Self::Empty => String::new(), + Self::Label(label) => label.serialize(), + } + } +} + +impl INode for Vec { + fn serialize(&self) -> String { + self.iter() + .map(|s| s.serialize()) + .collect::>() + .join("\n") + } +} + +#[derive(Debug)] +struct Parser<'a> { + lines: std::str::Lines<'a>, + cur_line: &'a str, + cur_pos: usize, + line_num: u64, + cur_line_chars: Vec<&'a str>, +} + +impl<'a> Parser<'a> { + fn new(data: &'a str) -> Self { + Self { + lines: data.lines(), + cur_line: "", + cur_pos: 0, + line_num: 0, + cur_line_chars: Vec::new(), + } + } + + fn error(&self, msg: impl std::fmt::Display) -> anyhow::Error { + anyhow::anyhow!("{} at line {} char {}", msg, self.line_num, self.cur_pos) + } + + fn parse(mut self) -> Result> { + let mut lines = Vec::new(); + while let Some(line) = self.lines.next() { + self.line_num += 1; + self.cur_line = line; + self.cur_line_chars = line.graphemes(true).collect(); + lines.push(self.parse_line()?); + } + Ok(lines) + } + + fn add_next_line(&mut self) -> Result<()> { + let next_line = self + .lines + .next() + .ok_or_else(|| anyhow::anyhow!("Unexpected eof"))?; + self.line_num += 1; + self.cur_line = next_line; + self.cur_line_chars.push("\n"); + self.cur_line_chars.extend(next_line.graphemes(true)); + Ok(()) + } + + fn parse_line(&mut self) -> Result { + self.cur_pos = 0; + if self.cur_line.trim_matches(' ').is_empty() { + return Ok(Line::Empty); + } + let mut line = Vec::new(); + let mut text = String::new(); + while let Some(c) = self.peek_char() { + // Skip space if text is empty + if text.is_empty() && (c == " " || c == "\t") { + self.cur_pos += 1; + continue; + } + // Label + if line.is_empty() && c == "#" { + self.cur_pos += 1; + let label = self.cur_line_chars[self.cur_pos..].join(""); + return Ok(Line::Label(LabelNode(label))); + } + if c == "/" { + // Comment + if let Some(c) = self.peek_char_offset(1) { + if c == "/" { + let ctext = text.trim_end_matches(' ').trim_end_matches('\t'); + if !ctext.is_empty() { + line.push(LineNode::Text(TextNode(ctext.to_owned()))); + text.clear(); + } + self.cur_pos += 2; + let comment = self.cur_line_chars[self.cur_pos..].join(""); + line.push(LineNode::Comment(CommentNode(comment))); + break; + } else if c == "*" { + let ctext = text.trim_end_matches(' ').trim_end_matches('\t'); + if !ctext.is_empty() { + line.push(LineNode::Text(TextNode(ctext.to_owned()))); + text.clear(); + } + self.cur_pos += 2; + let start_pos = self.cur_pos; + let mut ok = false; + loop { + while let Some(c) = self.next_char() { + if c == "*" && self.peek_char().is_some_and(|c| c == "/") { + let end_pos = self.cur_pos - 1; + self.cur_pos += 1; + ok = true; + line.push(LineNode::Comments(CommentBlock( + self.cur_line_chars[start_pos..end_pos].join(""), + ))); + break; + } + } + if ok { + break; + } + self.add_next_line()?; + } + continue; + } + } + } + // command + if c == "\\" { + // check \R + if self.peek_char_offset(1).is_some_and(|c| c == "R") { + self.cur_pos += 2; + text.push_str("\\R"); + continue; + } + let ctext = text.trim_end_matches(' ').trim_end_matches('\t'); + if !ctext.is_empty() { + line.push(LineNode::Text(TextNode(ctext.to_owned()))); + text.clear(); + } + line.push(LineNode::Command(self.parse_command()?)); + continue; + } + // name + if c == "【" { + let ctext = text.trim_end_matches(' ').trim_end_matches('\t'); + if !ctext.is_empty() { + line.push(LineNode::Text(TextNode(ctext.to_owned()))); + text.clear(); + } + line.push(LineNode::Name(self.parse_name()?)); + continue; + } + text.push_str(c); + self.cur_pos += 1; + } + let ctext = text.trim_end_matches(' ').trim_end_matches('\t'); + if !ctext.is_empty() { + line.push(LineNode::Text(TextNode(ctext.to_owned()))); + } + Ok(Line::Line(line)) + } + + fn parse_command(&mut self) -> Result { + let c = self + .next_char() + .ok_or_else(|| self.error("Unexpected end of line"))?; + if c != "\\" { + return Err(self.error("Unexpected command start token")); + } + let mut name = String::new(); + let mut args = Vec::new(); + let mut in_quote = false; + let mut arg = String::new(); + let mut ok = false; + while let Some(c) = self.peek_char() { + if c == "(" { + ok = true; + self.cur_pos += 1; + break; + } + if c == ")" { + return Err(self.error("Unexpected ) when parsing command")); + } + if !c.is_ascii() { + break; + } + name.push_str(c); + self.cur_pos += 1; + continue; + } + if !ok { + return Ok(CommandNode { + name: name.trim_matches(' ').trim_matches('\t').to_owned(), + args: Vec::new(), + has_args: false, + }); + } + loop { + let c = self + .next_char() + .ok_or_else(|| self.error("Unexpected end of line when parsing command"))?; + if in_quote { + if c == "\"" { + in_quote = false; + continue; + } + } else { + if c == "\"" { + in_quote = true; + continue; + } + if c == " " || c == "\t" { + if arg.is_empty() { + continue; + } + let mut tmp = c.to_string(); + while let Some(c) = self.peek_char() { + if c == " " || c == "\t" { + self.cur_pos += 1; + tmp.push_str(c); + } else if c == "," || c == ")" { + break; + } else { + arg.push_str(&tmp); + break; + } + } + continue; + } + if c == "," { + args.push(arg); + arg = String::new(); + continue; + } + if c == ")" { + args.push(arg); + return Ok(CommandNode { + name: name.trim_matches(' ').trim_matches('\t').to_owned(), + args, + has_args: true, + }); + } + } + arg.push_str(c); + } + } + + fn parse_name(&mut self) -> Result { + let c = self + .next_char() + .ok_or_else(|| self.error("Unexpected end of line"))?; + if c != "【" { + return Err(self.error("Unexpected command start token")); + } + let mut name = String::new(); + loop { + let c = self + .next_char() + .ok_or_else(|| self.error("Unexpected end of line when parsing name"))?; + if c == "】" { + return Ok(NameNode(name)); + } + name.push_str(c); + } + } + + fn peek_char(&self) -> Option<&'a str> { + self.cur_line_chars.get(self.cur_pos).map(|s| *s) + } + + fn peek_char_offset(&self, offset: isize) -> Option<&'a str> { + let target = (self.cur_pos as isize + offset as isize) as usize; + self.cur_line_chars.get(target).map(|s| *s) + } + + fn next_char(&mut self) -> Option<&'a str> { + let t = self.cur_line_chars.get(self.cur_pos).map(|s| *s); + if t.is_some() { + self.cur_pos += 1; + } + t + } +} + +#[derive(Debug)] +pub struct YurisTxt { + data: Vec, + bom: BomType, +} + +impl YurisTxt { + pub fn new + ?Sized>(data: &D, encoding: Encoding) -> Result { + let (text, bom) = decode_with_bom_detect(encoding, data.as_ref(), true)?; + let data = Parser::new(&text).parse()?; + Ok(Self { data, bom }) + } +} + +impl Script for YurisTxt { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Json + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn extract_messages(&self) -> Result> { + let mut messages = Vec::new(); + for line in &self.data { + if let Line::Line(line) = line { + let mut name = None; + let mut message = String::new(); + for node in line.iter() { + if let LineNode::Name(n) = node { + name = Some(n.as_str()); + } else if let LineNode::Text(text) = node { + message.push_str(&text.replace("\\R", "\n")); + } else if let LineNode::Command(cmd) = node { + if !message.is_empty() { + message.push_str(&cmd.serialize()); + } + if cmd.name == "SEL" { + for arg in &cmd.args { + messages.push(Message::new(arg.to_owned(), None)); + } + } + } + } + if !message.is_empty() { + messages.push(Message::new(message, name.map(|s| s.to_owned()))); + } + } + } + Ok(messages) + } + + fn import_messages<'a>( + &'a self, + messages: Vec, + mut file: Box, + _filename: &str, + encoding: Encoding, + replacement: Option<&'a ReplacementTable>, + ) -> Result<()> { + let mut data = self.data.clone(); + let mut mess = messages.iter(); + let mut mes = mess.next(); + for line in data.iter_mut() { + if let Line::Line(line) = line { + let mut name_index = None; + let mut message_index = None; + for (i, node) in line.iter_mut().enumerate() { + if let LineNode::Name(_) = node { + name_index = Some(i); + } else if let LineNode::Text(_) = node { + if message_index.is_none() { + message_index = Some(i); + } + } else if let LineNode::Command(cmd) = node { + if cmd.name == "SEL" { + for arg in cmd.args.iter_mut() { + let mut m = mes + .ok_or_else(|| anyhow::anyhow!("No more messages to import"))? + .message + .clone(); + mes = mess.next(); + if let Some(rep) = replacement { + for (k, v) in &rep.map { + m = m.replace(k, v); + } + } + *arg = m; + } + } + } + } + if let Some(message_idx) = message_index { + let m = mes.ok_or_else(|| anyhow::anyhow!("No more messages to import"))?; + mes = mess.next(); + if let Some(name_idx) = name_index { + let mut name = m + .name + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Message don't have name"))? + .clone(); + if let Some(rep) = replacement { + for (k, v) in &rep.map { + name = name.replace(k, v); + } + } + if let LineNode::Name(n) = &mut line[name_idx] { + n.0 = name; + } + } + let mut m = m.message.replace("\n", "\\R"); + if let Some(rep) = replacement { + for (k, v) in &rep.map { + m = m.replace(k, v); + } + } + let data = Parser::new(&m).parse()?; + if data.len() != 1 { + anyhow::bail!("parsed length is not 1."); + } + let li = data[0].clone(); + match li { + Line::Label(_) => anyhow::bail!("Unsupported"), + Line::Empty => { + line.splice(message_idx.., []); + } + Line::Line(li) => { + line.splice(message_idx.., li); + } + } + } + } + } + if mes.is_some() || mess.next().is_some() { + return Err(anyhow::anyhow!("Some messages were not processed.")); + } + let data = data.serialize(); + let data = encode_string_with_bom(encoding, &data, false, self.bom)?; + file.write_all(&data)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse1() { + let data = "\\T( , 250 ) \t【name】\t「……なんて」\t"; + assert_eq!( + Parser::new(data).parse().unwrap(), + vec![Line::Line(vec![ + LineNode::Command(CommandNode { + name: "T".into(), + args: vec!["".into(), "250".into()], + has_args: true, + }), + LineNode::Name(NameNode("name".into())), + LineNode::Text(TextNode("「……なんて」".into())), + ])] + ); + } + #[test] + fn test_parse2() { + let data = "\\T(2 5 \t0\t ) //TEST\n\\T ( \"250 \" , \"Wor,ks\" )"; + assert_eq!( + Parser::new(data).parse().unwrap(), + vec![ + Line::Line(vec![ + LineNode::Command(CommandNode { + name: "T".into(), + args: vec!["2 5 \t0".into()], + has_args: true, + }), + LineNode::Comment(CommentNode("TEST".into())) + ]), + Line::Line(vec![LineNode::Command(CommandNode { + name: "T".into(), + args: vec!["250 ".into(), "Wor,ks".into()], + has_args: true, + }),]) + ] + ); + } + #[test] + fn test_parse3() { + let data = "\\VO(UDA_0_ALL_0007_0004)【ウダツ】「んで、昨日あの後どうしたん? 実習の日程はもう決まった\\Rのか?」"; + assert_eq!( + Parser::new(data).parse().unwrap(), + vec![Line::Line(vec![ + LineNode::Command(CommandNode { + name: "VO".into(), + args: vec!["UDA_0_ALL_0007_0004".into()], + has_args: true, + }), + LineNode::Name(NameNode("ウダツ".into())), + LineNode::Text(TextNode( + "「んで、昨日あの後どうしたん? 実習の日程はもう決まった\\Rのか?」".into() + )), + ])] + ); + } + #[test] + fn test_parse4() { + let data = "\\GO.TITLE"; + assert_eq!( + Parser::new(data).parse().unwrap(), + vec![Line::Line(vec![LineNode::Command(CommandNode { + name: "GO.TITLE".into(), + args: vec![], + has_args: false, + }),])] + ); + } + #[test] + fn test_parse5() { + let data = r"TEST/* +\FOUT(600, 42, white) +\BG.CMXYZ( 402, 0, -45) +\BG(bg51 , 260, 0, 0) +\PSET(回想フレーム, 0) +\FIN(600, 41) +*/Test"; + assert_eq!( + Parser::new(data).parse().unwrap(), + vec![Line::Line(vec![ + LineNode::Text(TextNode("TEST".into())), + LineNode::Comments(CommentBlock( + r" +\FOUT(600, 42, white) +\BG.CMXYZ( 402, 0, -45) +\BG(bg51 , 260, 0, 0) +\PSET(回想フレーム, 0) +\FIN(600, 41) +" + .into() + )), + LineNode::Text(TextNode("Test".into())), + ])] + ); + } +} diff --git a/src/types.rs b/src/types.rs index cbcea8c..6e9b8ff 100644 --- a/src/types.rs +++ b/src/types.rs @@ -923,6 +923,9 @@ pub enum ScriptType { #[cfg(feature = "yuris")] /// Yu-Ris YSTB(compiled script) file (.ybn) YurisYSTB, + #[cfg(feature = "yuris")] + /// Yu-Ris scenario text file (.txt) + YurisTxt, } #[derive(Clone, Debug, Serialize, Deserialize)]