瀏覽代碼

start implement file format

Getty Ritter 4 月之前
父節點
當前提交
6fb11bfbd2
共有 11 個文件被更改,包括 440 次插入28 次删除
  1. 106 1
      Cargo.lock
  2. 7 1
      Cargo.toml
  3. 5 0
      README.md
  4. 48 8
      src/data.rs
  5. 64 3
      src/errors.rs
  6. 164 15
      src/file.rs
  7. 1 0
      tools/editor/main.rs
  8. 0 0
      tools/svg-from-png/main.rs
  9. 0 0
      tools/svg-from-png/opts.rs
  10. 36 0
      tools/thyme-from-png/main.rs
  11. 9 0
      tools/thyme-from-png/opts.rs

+ 106 - 1
Cargo.lock

@@ -43,6 +43,33 @@ version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bzip2"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
+dependencies = [
+ "bzip2-sys",
+ "libc",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.11+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
 [[package]]
 name = "cairo-rs"
 version = "0.15.1"
@@ -67,6 +94,15 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "cc"
+version = "1.0.83"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "cfg-expr"
 version = "0.9.0"
@@ -194,6 +230,16 @@ version = "0.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
 
+[[package]]
+name = "flate2"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.7.1",
+]
+
 [[package]]
 name = "futures-channel"
 version = "0.3.19"
@@ -335,6 +381,12 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "itoa"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
+
 [[package]]
 name = "lazy_static"
 version = "1.4.0"
@@ -369,6 +421,15 @@ dependencies = [
  "autocfg",
 ]
 
+[[package]]
+name = "miniz_oxide"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
+dependencies = [
+ "adler",
+]
+
 [[package]]
 name = "once_cell"
 version = "1.9.0"
@@ -464,7 +525,7 @@ dependencies = [
  "crc32fast",
  "deflate",
  "encoding",
- "miniz_oxide",
+ "miniz_oxide 0.4.4",
 ]
 
 [[package]]
@@ -545,6 +606,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "serde_json"
+version = "1.0.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46266871c240a00b8f503b877622fe33430b3c7d963bdc0f2adc511e54a1eae3"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
 [[package]]
 name = "serde_yaml"
 version = "0.8.23"
@@ -644,7 +716,20 @@ dependencies = [
  "pangocairo",
  "png",
  "serde",
+ "serde_json",
  "serde_yaml",
+ "zip",
+]
+
+[[package]]
+name = "time"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a"
+dependencies = [
+ "libc",
+ "wasi",
+ "winapi",
 ]
 
 [[package]]
@@ -680,6 +765,12 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "wasi"
+version = "0.10.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -719,3 +810,17 @@ checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
 dependencies = [
  "linked-hash-map",
 ]
+
+[[package]]
+name = "zip"
+version = "0.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93ab48844d61251bb3835145c521d88aa4031d7139e8485990f60ca911fa0815"
+dependencies = [
+ "byteorder",
+ "bzip2",
+ "crc32fast",
+ "flate2",
+ "thiserror",
+ "time",
+]

+ 7 - 1
Cargo.toml

@@ -13,12 +13,18 @@ cairo-rs = { version = "*", features = ["svg"] }
 pango = "*"
 pangocairo = "*"
 serde = { version = "*", features = ["derive"] }
+serde_json = "*"
 serde_yaml = "*"
 clap = { version = "*", features = ["derive"] }
+zip = "*"
 
 [[bin]]
 name = "thyme-from-png"
-path = "tools/from-png/main.rs"
+path = "tools/thyme-from-png/main.rs"
+
+[[bin]]
+name = "svg-from-png"
+path = "tools/svg-from-png/main.rs"
 
 [[bin]]
 name = "thyme-editor"

+ 5 - 0
README.md

@@ -0,0 +1,5 @@
+`thyme` is a tool for creating cross-stitch patterns from PNG files of pixel art.
+
+# Thyme file format
+
+A _thyme_ file is a packed binary file. It always begin with the header `0x7468796d` (i.e. `thym` in hex.)

+ 48 - 8
src/data.rs

@@ -1,16 +1,47 @@
 use serde::Deserialize;
+use serde_yaml::Value;
 
 use crate::errors::IncompleteMappingError;
 use crate::image::{Image, Pixel};
 use std::collections::{HashMap, HashSet};
 
 #[derive(Deserialize, Debug)]
-pub struct Mapping(pub HashMap<Pixel, String>);
+pub struct Mapping(pub HashMap<Pixel, Option<ColorEntry>>);
 
 #[derive(Deserialize, Debug)]
-struct ColorEntry {
-    color: Pixel,
-    symbol: String,
+pub struct ColorEntry {
+    pub color: Pixel,
+    pub name: String,
+    pub symbol: String,
+}
+
+impl ColorEntry {
+    fn from_entry(
+        entry: HashMap<String, Value>,
+    ) -> Result<(Pixel, Option<ColorEntry>), Box<dyn std::error::Error>> {
+        let color = match entry.get("color") {
+            Some(Value::Sequence(v)) => {
+                let r = v[0].as_u64().unwrap().try_into().unwrap();
+                let g = v[1].as_u64().unwrap().try_into().unwrap();
+                let b = v[2].as_u64().unwrap().try_into().unwrap();
+                (r, g, b)
+            }
+            _ => panic!("Missing `color` in entry"),
+        };
+        if let Some(_) = entry.get("blank") {
+            return Ok((color, None));
+        }
+        let name = entry["name"].as_str().unwrap().to_owned();
+        let symbol = entry["symbol"].as_str().unwrap().to_owned();
+        Ok((
+            color,
+            Some(ColorEntry {
+                color,
+                name,
+                symbol,
+            }),
+        ))
+    }
 }
 
 impl Mapping {
@@ -19,12 +50,17 @@ impl Mapping {
         img: &Image,
     ) -> Result<Mapping, Box<dyn std::error::Error>> {
         let path = path.as_ref().to_path_buf();
-        let data: Vec<ColorEntry> = serde_yaml::from_reader(std::fs::File::open(&path)?)?;
+        let data: Vec<HashMap<String, Value>> =
+            serde_yaml::from_reader(std::fs::File::open(&path)?)?;
         // do validation to make sure all pixel colors are handled
         let color_map = data
             .into_iter()
-            .map(|entry| (entry.color, entry.symbol))
-            .collect::<HashMap<Pixel, String>>();
+            .map(|entry| {
+                let (c, e) = ColorEntry::from_entry(entry)?;
+                Ok((c, e))
+            })
+            .collect::<Result<HashMap<Pixel, Option<ColorEntry>>, Box<dyn std::error::Error>>>()?;
+        println!("{:?}", color_map);
         let all_image_colors = img
             .iter()
             .map(|(_, color)| color)
@@ -43,7 +79,11 @@ impl Mapping {
     }
 
     pub fn lookup(&self, color: Pixel) -> &str {
-        &self.0[&color]
+        if let Some(ref e) = self.0[&color] {
+            &e.symbol
+        } else {
+            ""
+        }
     }
 }
 

+ 64 - 3
src/errors.rs

@@ -1,13 +1,16 @@
 use crate::image::Pixel;
 
+use std::error::Error;
+use std::fmt::{Display, Formatter, Result};
+
 #[derive(Debug)]
 pub struct IncompleteMappingError {
     pub path: std::path::PathBuf,
     pub missing_colors: Vec<Pixel>,
 }
 
-impl std::fmt::Display for IncompleteMappingError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+impl Display for IncompleteMappingError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
         writeln!(
             f,
             "`{:?}` is missing entries for the following pixel colors:",
@@ -20,4 +23,62 @@ impl std::fmt::Display for IncompleteMappingError {
     }
 }
 
-impl std::error::Error for IncompleteMappingError {}
+impl Error for IncompleteMappingError {}
+
+#[derive(Debug)]
+pub struct ThymeFileStructureError {
+    pub message: String,
+}
+
+impl ThymeFileStructureError {
+    pub fn new(msg: impl Into<String>) -> Self {
+        ThymeFileStructureError {
+            message: msg.into(),
+        }
+    }
+}
+
+impl Display for ThymeFileStructureError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
+        writeln!(f, "{}", self.message)
+    }
+}
+
+impl Error for ThymeFileStructureError {}
+
+#[derive(Debug)]
+pub enum ThymeFileError {
+    StructureError(ThymeFileStructureError),
+    ZipError(zip::result::ZipError),
+    JsonError(serde_json::Error),
+}
+
+impl Display for ThymeFileError {
+    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
+        match self {
+            ThymeFileError::StructureError(err) => write!(f, "Error parsing file: {}", err),
+            ThymeFileError::ZipError(err) => write!(f, "Error with zip wrapper: {}", err),
+            ThymeFileError::JsonError(err) => write!(f, "Error parsing JSON: {}", err),
+        }
+    }
+}
+
+impl Error for ThymeFileError {}
+
+impl From<zip::result::ZipError> for ThymeFileError {
+    fn from(err: zip::result::ZipError) -> Self {
+        ThymeFileError::ZipError(err)
+    }
+}
+
+impl From<serde_json::Error> for ThymeFileError {
+    fn from(err: serde_json::Error) -> Self {
+        ThymeFileError::JsonError(err)
+    }
+}
+
+impl From<ThymeFileStructureError> for ThymeFileError {
+    fn from(err: ThymeFileStructureError) -> Self {
+        ThymeFileError::StructureError(err)
+    }
+}

+ 164 - 15
src/file.rs

@@ -1,7 +1,12 @@
 use crate::data::Mapping;
+use crate::errors::{ThymeFileError, ThymeFileStructureError};
 use crate::image::{Image, Pixel};
 
+use serde::{Deserialize, Serialize};
 use std::collections::HashMap;
+use std::io::{Read, Seek, Write};
+
+const CURRENT_VERSION: &'static str = "0";
 
 #[derive(Debug, Clone)]
 pub enum StitchType {
@@ -10,13 +15,23 @@ pub enum StitchType {
     HalfDown,
 }
 
+impl StitchType {
+    fn to_u8(&self) -> u8 {
+        match self {
+            StitchType::Normal => 'x' as u8,
+            StitchType::HalfUp => '/' as u8,
+            StitchType::HalfDown => '\\' as u8,
+        }
+    }
+}
+
 #[derive(Debug, Clone)]
 pub struct Stitch {
     pub color: ColorIdx,
     pub typ: StitchType,
 }
 
-#[derive(Debug)]
+#[derive(Debug, Serialize, Deserialize)]
 pub struct Color {
     pub name: String,
     pub color: Pixel,
@@ -24,7 +39,7 @@ pub struct Color {
 
 #[derive(Debug, Clone, Copy)]
 pub struct ColorIdx {
-    pub idx: usize,
+    pub idx: u32,
 }
 
 #[derive(Debug)]
@@ -33,6 +48,7 @@ pub struct ThymeFile {
     pub height: u32,
     pub palette: Vec<Color>,
     pub payload: Vec<Option<Stitch>>,
+    pub metadata: HashMap<String, String>,
 }
 
 impl ThymeFile {
@@ -40,15 +56,25 @@ impl ThymeFile {
         let width = image.width;
         let height = image.height;
 
-        let lookup = mapping
-            .iter()
-            .enumerate()
-            .map(|(idx, (pixel, _))| (*pixel, ColorIdx { idx }))
-            .collect::<HashMap<Pixel, ColorIdx>>();
+        let mut palette = Vec::new();
+        let mut lookup = HashMap::new();
+        let mut next_idx = 0;
+        for (color, info) in mapping.iter() {
+            if let Some(i) = info {
+                let idx = ColorIdx { idx: next_idx };
+                next_idx += 1;
+                palette.push(Color {
+                    color: *color,
+                    name: i.name.to_owned(),
+                });
+                lookup.insert(color, idx);
+            }
+        }
 
         let payload = image
             .iter()
-            .map(|(_, pixel)| {
+            .map(|(idx, pixel)| {
+                println!("{:?}: => {:?}", idx, lookup.get(&pixel));
                 if let Some(color) = lookup.get(&pixel) {
                     Some(Stitch {
                         color: *color,
@@ -60,19 +86,14 @@ impl ThymeFile {
             })
             .collect();
 
-        let palette = mapping
-            .iter()
-            .map(|(color, name)| Color {
-                color: *color,
-                name: name.clone(),
-            })
-            .collect();
+        let metadata = HashMap::new();
 
         ThymeFile {
             width,
             height,
             palette,
             payload,
+            metadata,
         }
     }
 
@@ -83,11 +104,139 @@ impl ThymeFile {
             payload.push(None);
         }
 
+        let metadata = HashMap::new();
+
         ThymeFile {
             width,
             height,
             palette,
             payload,
+            metadata,
+        }
+    }
+
+    pub fn from_stream<R>(stream: &mut R) -> Result<ThymeFile, ThymeFileError>
+    where
+        R: Read + Seek,
+    {
+        fn missing_component(component: &str) -> ThymeFileError {
+            ThymeFileStructureError::new(format!(
+                "Missing component in thyme file: `{}`",
+                component
+            ))
+            .into()
+        }
+        let mut zip = zip::ZipArchive::new(stream)?;
+        let mut width = None;
+        let mut height = None;
+        let mut metadata = None;
+        let mut palette = None;
+        let mut payload = None;
+        for i in 0..zip.len() {
+            let file = zip.by_index(i)?;
+            match file.name() {
+                "THYME_VERSION" => {
+                    // TODO: actually check this
+                }
+                "dimensions.json" => {
+                    let (w, h): (u32, u32) = serde_json::from_reader(file)?;
+                    width = Some(w);
+                    height = Some(h);
+                }
+                "metadata.json" => metadata = Some(serde_json::from_reader(file)?),
+                "palette.json" => palette = Some(serde_json::from_reader(file)?),
+                "payload.json" => {
+                    let ps: Vec<Option<IntermediateStitch>> = serde_json::from_reader(file)?;
+                    let ps: Result<Vec<Option<Stitch>>, ThymeFileError> = ps
+                        .iter()
+                        .map(|n| n.map(IntermediateStitch::to_stitch).transpose())
+                        .collect();
+                    payload = Some(ps?);
+                }
+                name => {
+                    return Err(ThymeFileStructureError::new(format!(
+                        "Unrecognized element: {}",
+                        name
+                    ))
+                    .into())
+                }
+            }
+            // whatever
         }
+        Ok(ThymeFile {
+            width: width.ok_or_else(|| missing_component("dimensions"))?,
+            height: height.ok_or_else(|| missing_component("dimensions"))?,
+            palette: palette.ok_or_else(|| missing_component("palette"))?,
+            payload: payload.ok_or_else(|| missing_component("payload"))?,
+            metadata: metadata.ok_or_else(|| missing_component("metadata"))?,
+        })
+    }
+
+    pub fn to_stream<W>(&self, stream: &mut W) -> std::io::Result<()>
+    where
+        W: Write + Seek,
+    {
+        let mut zip = zip::ZipWriter::new(stream);
+        let options =
+            zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Bzip2);
+
+        // add version metadata
+        zip.start_file("THYME_VERSION", options)?;
+        writeln!(zip, "{}", CURRENT_VERSION)?;
+
+        // add dimensions
+        zip.start_file("dimensions.json", options)?;
+        serde_json::to_writer(&mut zip, &(self.width, self.height))?;
+
+        // add metadata
+        zip.start_file("metadata.json", options)?;
+        serde_json::to_writer(&mut zip, &self.metadata)?;
+
+        // add palette
+        zip.start_file("palette.json", options)?;
+        serde_json::to_writer(&mut zip, &self.palette)?;
+
+        // add image payload
+        zip.start_file("payload.json", options)?;
+        serde_json::to_writer(&mut zip, &self.json_stitches())?;
+
+        // add version
+        zip.finish()?;
+        Ok(())
+    }
+
+    fn json_stitches(&self) -> Vec<Option<IntermediateStitch>> {
+        self.payload
+            .iter()
+            .map(|stitch| match stitch {
+                Some(s) => Some(IntermediateStitch(s.typ.to_u8(), s.color.idx)),
+                None => None,
+            })
+            .collect()
+    }
+}
+
+// internal structs for serializing/deserializing the image part
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+struct IntermediateStitch(u8, u32);
+
+impl IntermediateStitch {
+    fn to_stitch(self) -> Result<Stitch, ThymeFileError> {
+        let typ = match self.0 {
+            b'x' => StitchType::Normal,
+            b'/' => StitchType::HalfUp,
+            b'\\' => StitchType::HalfDown,
+            _ => {
+                return Err(ThymeFileStructureError::new(format!(
+                    "Unknown stitch type: {}",
+                    self.0
+                ))
+                .into())
+            }
+        };
+        Ok(Stitch {
+            typ,
+            color: ColorIdx { idx: self.1 },
+        })
     }
 }

+ 1 - 0
tools/editor/main.rs

@@ -0,0 +1 @@
+fn main() {}

tools/from-png/main.rs → tools/svg-from-png/main.rs


tools/from-png/opts.rs → tools/svg-from-png/opts.rs


+ 36 - 0
tools/thyme-from-png/main.rs

@@ -0,0 +1,36 @@
+mod opts;
+
+use crate::opts::Options;
+use clap::Parser;
+use std::path::Path;
+use thyme::{data::Mapping, file::ThymeFile, image::Image};
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    // read the command-line options
+    let opts = Options::parse();
+
+    let image_path = Path::new(&opts.image);
+
+    // load the PNG image for the pattern
+    let image = Image::load(image_path)?;
+    // load the color map file
+    let mapping = Mapping::load(opts.mapping, &image)?;
+
+    let thyme = ThymeFile::from_image_and_config(&image, &mapping);
+
+    let output_filename = match opts.output {
+        Some(s) => Path::new(&s).to_path_buf(),
+        None => {
+            let mut path = image_path.to_path_buf();
+            path.set_extension("thyme");
+            path
+        }
+    };
+
+    {
+        let mut f = std::fs::File::create(output_filename)?;
+        thyme.to_stream(&mut f)?;
+    }
+
+    Ok(())
+}

+ 9 - 0
tools/thyme-from-png/opts.rs

@@ -0,0 +1,9 @@
+use clap::Parser;
+
+#[derive(Parser, Debug)]
+#[clap(author, version, about, long_about = None)]
+pub struct Options {
+    pub image: String,
+    pub mapping: String,
+    pub output: Option<String>,
+}