mod error;
pub use error::*;
use crate::gresource::xml::PreprocessOptions;
use crate::write::{FileWriter, HashTableBuilder};
use flate2::write::ZlibEncoder;
use std::borrow::Cow;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
const FLAG_COMPRESSED: u32 = 1 << 0;
static SKIPPED_FILE_EXTENSIONS_DEFAULT: &[&str] =
&["meson.build", "gresource.xml", ".gitignore", ".license"];
static COMPRESS_EXTENSIONS_DEFAULT: &[&str] = &[".ui", ".css"];
#[derive(Debug)]
pub struct FileData<'a> {
key: String,
data: Cow<'a, [u8]>,
flags: u32,
size: u32,
}
impl<'a> FileData<'a> {
pub fn new(
key: String,
data: Cow<'a, [u8]>,
path: Option<PathBuf>,
compressed: bool,
preprocess: &PreprocessOptions,
) -> BuilderResult<Self> {
let mut flags = 0;
let mut data = Self::preprocess(data, preprocess, path.clone())?;
let size = data.len() as u32;
if compressed {
data = Self::compress(data, path)?;
flags |= FLAG_COMPRESSED;
} else {
data.to_mut().push(0);
}
Ok(Self {
key,
data,
flags,
size,
})
}
pub fn from_file(
key: String,
file_path: &Path,
compressed: bool,
preprocess: &PreprocessOptions,
) -> BuilderResult<Self> {
let mut open_file = std::fs::File::open(file_path)
.map_err(BuilderError::from_io_with_filename(Some(file_path)))?;
let mut data = Vec::new();
open_file
.read_to_end(&mut data)
.map_err(BuilderError::from_io_with_filename(Some(file_path)))?;
FileData::new(
key,
Cow::Owned(data),
Some(file_path.to_path_buf()),
compressed,
preprocess,
)
}
fn xml_stripblanks(data: Cow<'a, [u8]>, path: Option<PathBuf>) -> BuilderResult<Cow<'a, [u8]>> {
let output = Vec::new();
let mut reader = quick_xml::Reader::from_str(
std::str::from_utf8(&data).map_err(|err| BuilderError::Utf8(err, path.clone()))?,
);
let mut writer = quick_xml::Writer::new(std::io::Cursor::new(output));
fn has_nonempty_text_node(events: &[quick_xml::events::Event<'_>]) -> bool {
let mut nesting = 0;
for event in events {
match event {
quick_xml::events::Event::Start(_) => {
nesting += 1;
}
quick_xml::events::Event::Eof => {
break;
}
quick_xml::events::Event::End(_) => {
if nesting == 0 {
break;
}
nesting -= 1;
}
quick_xml::events::Event::Text(text) => {
if nesting == 0 && text.iter().any(|c| !c.is_ascii_whitespace()) {
return true;
}
}
_ => {}
}
}
false
}
fn strip_inside<'a, W: std::io::Write>(
events: &mut std::slice::IterMut<'a, quick_xml::events::Event<'a>>,
writer: &mut quick_xml::Writer<W>,
) -> Result<(), quick_xml::errors::Error> {
let has_text_node = has_nonempty_text_node(events.as_slice());
while let Some(event) = events.next() {
if !has_text_node && let quick_xml::events::Event::Text(text) = event {
let mut empty = false;
empty |= text.inplace_trim_start();
empty |= text.inplace_trim_end();
if empty {
continue;
}
}
writer.write_event(event.clone())?;
match &event {
quick_xml::events::Event::Start(_) => strip_inside(&mut *events, writer)?,
quick_xml::events::Event::End(_) | quick_xml::events::Event::Eof => {
break;
}
_ => {}
}
}
Ok(())
}
let mut events = Vec::new();
loop {
let event = reader
.read_event()
.map_err(|err| BuilderError::Xml(err, path.clone()))?;
if matches!(event, quick_xml::events::Event::Eof) {
events.push(event);
break;
}
events.push(event);
}
strip_inside(&mut events.iter_mut(), &mut writer)
.map_err(|err| BuilderError::Xml(err, path.clone()))?;
Ok(Cow::Owned(writer.into_inner().into_inner()))
}
fn json_stripblanks(
data: Cow<'a, [u8]>,
path: Option<PathBuf>,
) -> BuilderResult<Cow<'a, [u8]>> {
let string =
std::str::from_utf8(&data).map_err(|err| BuilderError::Utf8(err, path.clone()))?;
let json: serde_json::Value =
serde_json::from_str(string).map_err(|err| BuilderError::Json(err, path.clone()))?;
let mut output = json.to_string().as_bytes().to_vec();
output.push(b'\n');
Ok(Cow::Owned(output))
}
fn preprocess(
mut data: Cow<'a, [u8]>,
options: &PreprocessOptions,
path: Option<PathBuf>,
) -> BuilderResult<Cow<'a, [u8]>> {
if options.xml_stripblanks {
data = Self::xml_stripblanks(data, path.clone())?;
}
if options.json_stripblanks {
data = Self::json_stripblanks(data, path)?;
}
if options.to_pixdata {
return Err(BuilderError::Unimplemented(
"to-pixdata is deprecated since gdk-pixbuf 2.32 and not supported by gvdb-rs"
.to_string(),
));
}
Ok(data)
}
fn compress(data: Cow<'a, [u8]>, path: Option<PathBuf>) -> BuilderResult<Cow<'a, [u8]>> {
let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::best());
encoder
.write_all(&data)
.map_err(BuilderError::from_io_with_filename(path.clone()))?;
Ok(Cow::Owned(
encoder
.finish()
.map_err(BuilderError::from_io_with_filename(path))?,
))
}
pub fn key(&self) -> &str {
&self.key
}
}
impl std::cmp::PartialEq for FileData<'_> {
fn eq(&self, other: &Self) -> bool {
self.key == other.key
}
}
impl std::cmp::Eq for FileData<'_> {}
impl std::cmp::PartialOrd for FileData<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::cmp::Ord for FileData<'_> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.key.cmp(&other.key)
}
}
#[derive(zvariant::Type, zvariant::Value, zvariant::OwnedValue)]
pub struct Data {
size: u32,
flags: u32,
data: Vec<u8>,
}
#[derive(Debug)]
pub struct BundleBuilder<'a> {
files: Vec<FileData<'a>>,
}
impl<'a> BundleBuilder<'a> {
pub fn from_xml(xml: super::xml::XmlManifest) -> BuilderResult<Self> {
let mut files = Vec::new();
for gresource in &xml.gresources {
for file in &gresource.files {
let mut key = gresource.prefix.clone();
if !key.ends_with('/') {
key.push('/');
}
if let Some(alias) = &file.alias {
key.push_str(alias);
} else {
key.push_str(&file.filename);
}
let mut filename = xml.dir.clone();
filename.push(PathBuf::from(&file.filename));
let file_data =
FileData::from_file(key, &filename, file.compressed, &file.preprocess)?;
files.push(file_data);
}
}
Ok(Self { files })
}
pub fn from_directory(
prefix: &str,
directory: &Path,
strip_blanks: bool,
compress: bool,
) -> BuilderResult<Self> {
let compress_extensions = if compress {
COMPRESS_EXTENSIONS_DEFAULT
} else {
&[]
};
Self::from_directory_with_extensions(
prefix,
directory,
strip_blanks,
compress_extensions,
SKIPPED_FILE_EXTENSIONS_DEFAULT,
)
}
pub fn from_directory_with_extensions(
prefix: &str,
directory: &Path,
strip_blanks: bool,
compress_extensions: &[&str],
skipped_file_extensions: &[&str],
) -> BuilderResult<Self> {
let mut prefix = prefix.to_string();
if !prefix.ends_with('/') {
prefix.push('/');
}
let mut files = Vec::new();
'outer: for res in WalkDir::new(directory).into_iter() {
let entry = match res {
Ok(entry) => entry,
Err(err) => {
let path = err.path().map(|p| p.to_path_buf());
Err(BuilderError::Io(err.into(), path))?
}
};
if entry.path().is_file() {
let filename: &str = match entry.file_name().try_into() {
Ok(name) => name,
Err(err) => return Err(BuilderError::Utf8(err, Some(entry.path().to_owned()))),
};
for name in skipped_file_extensions {
if filename.ends_with(name) {
continue 'outer;
}
}
let mut compress_this = false;
for name in compress_extensions {
if filename.ends_with(name) {
compress_this = true;
break;
}
}
let file_abs_path = entry.path();
let file_path_relative = match file_abs_path.strip_prefix(directory) {
Ok(path) => path,
Err(err) => {
return Err(BuilderError::StripPrefix(err, file_abs_path.to_owned()));
}
};
let file_path_str_relative: &str = match file_path_relative.as_os_str().try_into() {
Ok(name) => name,
Err(err) => {
return Err(BuilderError::Utf8(err, Some(file_path_relative.to_owned())));
}
};
let options = if strip_blanks && file_path_str_relative.ends_with(".json") {
PreprocessOptions::json_stripblanks()
} else if strip_blanks && file_path_str_relative.ends_with(".xml")
|| file_path_str_relative.ends_with(".ui")
|| file_path_str_relative.ends_with(".svg")
{
PreprocessOptions::xml_stripblanks()
} else {
PreprocessOptions::empty()
};
let key = format!("{prefix}{file_path_str_relative}");
let file_data = FileData::from_file(key, file_abs_path, compress_this, &options)?;
files.push(file_data);
}
}
files.sort();
Ok(Self { files })
}
pub fn from_file_data(files: Vec<FileData<'a>>) -> Self {
Self { files }
}
pub fn build(self) -> BuilderResult<Vec<u8>> {
let builder = FileWriter::new();
let mut table_builder = HashTableBuilder::new();
for file_data in self.files.into_iter() {
let data = Data {
size: file_data.size,
flags: file_data.flags,
data: file_data.data.to_vec(),
};
table_builder.insert_value(file_data.key(), zvariant::Value::from(data))?;
}
Ok(builder.write_to_vec_with_table(table_builder)?)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::gresource::xml::XmlManifest;
use crate::read::File;
use crate::test::{GRESOURCE_DIR, GRESOURCE_XML, assert_is_file_3, byte_compare_file_3};
use matches::assert_matches;
use zvariant::Type;
#[test]
fn file_data() {
let doc = XmlManifest::from_file(&GRESOURCE_XML).unwrap();
let builder = BundleBuilder::from_xml(doc).unwrap();
for file in &builder.files {
assert!(file.key().starts_with("/gvdb/rs/test"));
assert!(
[
"/gvdb/rs/test/online-symbolic.svg",
"/gvdb/rs/test/icons/scalable/actions/send-symbolic.svg",
"/gvdb/rs/test/json/test.json",
"/gvdb/rs/test/test.css"
]
.contains(&file.key()),
"Unknown file with key: {}",
file.key()
);
for file2 in &builder.files {
if std::ptr::eq(file as *const FileData, file2 as *const FileData) {
assert_eq!(file, file2);
} else {
assert_ne!(file, file2);
}
}
}
}
#[test]
fn from_dir_file_data() {
for preprocess in [true, false] {
let builder = BundleBuilder::from_directory(
"/gvdb/rs/test",
&GRESOURCE_DIR,
preprocess,
preprocess,
)
.unwrap();
for file in builder.files {
assert!(file.key().starts_with("/gvdb/rs/test"));
assert!(
[
"/gvdb/rs/test/icons/scalable/actions/online-symbolic.svg",
"/gvdb/rs/test/icons/scalable/actions/send-symbolic.svg",
"/gvdb/rs/test/json/test.json",
"/gvdb/rs/test/test.css",
"/gvdb/rs/test/test3.gresource.xml"
]
.contains(&file.key()),
"Unknown file with key: {}",
file.key()
);
}
}
}
#[test]
fn from_dir_invalid() {
let res = BundleBuilder::from_directory(
"/gvdb/rs/test",
&PathBuf::from("INVALID_DIR"),
false,
false,
);
assert!(res.is_err());
let err = res.unwrap_err();
assert_matches!(err, BuilderError::Io(..));
}
#[test]
fn test_file_3() {
let doc = XmlManifest::from_file(&GRESOURCE_XML).unwrap();
let builder = BundleBuilder::from_xml(doc).unwrap();
let data = builder.build().unwrap();
let root = File::from_bytes(Cow::Owned(data)).unwrap();
assert_is_file_3(&root);
byte_compare_file_3(&root);
}
#[test]
fn to_pixdata() {
let path = GRESOURCE_DIR.join("json").join("test.json");
let mut options = PreprocessOptions::empty();
options.to_pixdata = true;
let err = FileData::from_file("test.json".to_string(), &path, false, &options).unwrap_err();
assert_matches!(err, BuilderError::Unimplemented(_));
assert!(format!("{err}").contains("to-pixdata is deprecated"));
}
#[test]
fn xml_stripblanks_pass() {
let xml = r#"
<xml>
<with> lots </with>
<of > spa ces</of>
</xml>"#;
let bytes = Cow::Borrowed(xml.as_bytes());
let stripped = FileData::xml_stripblanks(bytes, None).unwrap();
assert_eq!(
std::str::from_utf8(&stripped).unwrap(),
r#"<xml><with> lots </with><of > spa ces</of></xml>"#
);
}
#[test]
fn xml_stripblanks_with_accelerator() {
let xml = r#"
<child>
<object class="GtkShortcutsShortcut">
<property name="title" translatable="yes" context="shortcut window">Toggle Image Properties</property>
<property name="accelerator">F9 <Alt>Return</property>
</object>
</child>
"#;
let bytes = Cow::Borrowed(xml.as_bytes());
let stripped = FileData::xml_stripblanks(bytes, None).unwrap();
assert_eq!(
std::str::from_utf8(&stripped).unwrap(),
r#"<child><object class="GtkShortcutsShortcut"><property name="title" translatable="yes" context="shortcut window">Toggle Image Properties</property><property name="accelerator">F9 <Alt>Return</property></object></child>"#
);
}
#[test]
fn xml_stripblanks_significant_whitespace() {
let xml =
r#"<property name="title" translatable="yes"> General </property>"#;
let bytes = Cow::Borrowed(xml.as_bytes());
let stripped = FileData::xml_stripblanks(bytes, None).unwrap();
assert_eq!(
std::str::from_utf8(&stripped).unwrap(),
r#"<property name="title" translatable="yes"> General </property>"#
);
}
#[test]
fn xml_stripblanks_text_nodes() {
let xml = r#"
<interface> a <property> <with_enclosed_spaces /> </property> </interface>
"#;
let bytes = Cow::Borrowed(xml.as_bytes());
let stripped = FileData::xml_stripblanks(bytes, None).unwrap();
assert_eq!(
std::str::from_utf8(&stripped).unwrap(),
r#"<interface> a <property><with_enclosed_spaces /></property> </interface>"#
);
}
#[test]
fn xml_stripblanks_invalid_xml() {
for path in [Some(PathBuf::from("test")), None] {
let xml = "<invalid";
let err = FileData::new(
"test".to_string(),
Cow::Borrowed(xml.as_bytes()),
path,
false,
&PreprocessOptions::xml_stripblanks(),
)
.unwrap_err();
assert_matches!(err, BuilderError::Xml(_, _));
assert!(format!("{err}").contains("Error processing XML data"));
}
}
#[test]
fn json_stripblanks() {
for path in [Some(PathBuf::from("test")), None] {
let invalid_utf8 = [0xC3, 0x28];
let err = FileData::new(
"test".to_string(),
Cow::Borrowed(&invalid_utf8),
path.clone(),
false,
&PreprocessOptions::json_stripblanks(),
)
.unwrap_err();
assert_matches!(err, BuilderError::Utf8(..));
assert!(format!("{err:?}").contains("UTF-8"));
let invalid_json = r#"{ "test": : }"#.as_bytes();
let err = FileData::new(
"test".to_string(),
Cow::Borrowed(invalid_json),
path,
false,
&PreprocessOptions::json_stripblanks(),
)
.unwrap_err();
assert_matches!(err, BuilderError::Json(..));
assert!(format!("{err:?}").contains("expected value at line"));
}
let valid_json = r#"{ "test": "test" }"#.as_bytes();
let data = FileData::new(
"test".to_string(),
Cow::Borrowed(valid_json),
None,
false,
&PreprocessOptions::json_stripblanks(),
)
.unwrap();
let json = std::str::from_utf8(&data.data).unwrap();
assert_eq!(json, "{\"test\":\"test\"}\n\0");
}
#[test]
fn derives_data() {
let data = Data {
size: 3,
flags: 0,
data: vec![1, 2, 3],
};
let sig = Data::SIGNATURE;
assert_eq!(sig, "(uuay)");
let owned = zvariant::OwnedValue::try_from(data).unwrap();
let data = Data::try_from(owned).unwrap();
let value: zvariant::Value = data.into();
let _: Data = value.try_into().unwrap();
}
}