wagahai_lut 0.1.0

CUBE LUT parser and image processing library with SIMD
Documentation
/*
 * SPDX-FileCopyrightText: © 2026 Jinwoo Park (pmnxis@gmail.com)
 *
 * SPDX-License-Identifier: MIT
 */

//! CUBE LUT file parser

use crate::error::{CubeError, Result};
use crate::lut::{CubeLut, Lut1D, Lut3D, LutData, LutType};
use std::io::{BufRead, BufReader, Read};

/// Parser for CUBE LUT files
pub struct CubeParser;

impl CubeParser {
    /// Parse a CUBE LUT from a reader
    pub fn parse<R: Read>(reader: R) -> Result<CubeLut> {
        let buf_reader = BufReader::new(reader);
        let lines: Vec<String> = buf_reader.lines().collect::<std::result::Result<_, _>>()?;

        let mut title: Option<String> = None;
        let mut domain_min: Option<[f32; 3]> = None;
        let mut domain_max: Option<[f32; 3]> = None;
        let mut has_lut_size = false;
        let mut has_title = false;
        let mut has_domain_min = false;
        let mut has_domain_max = false;
        let mut lut_size: Option<usize> = None;
        let mut lut_type: Option<LutType> = None;
        let mut lut_data: Option<LutData> = None;

        // First pass: find keywords and table data start
        let mut keyword_lines = Vec::new();
        let mut table_data_start: Option<usize> = None;

        for (line_num, line) in lines.iter().enumerate() {
            let trimmed = line.trim();
            // Skip empty lines and comments
            if trimmed.is_empty() || trimmed.starts_with('#') {
                continue;
            }

            // Check if this is table data (starts with a number)
            if let Some(first_char) = trimmed.chars().next() {
                if first_char.is_ascii_digit() || first_char == '-' || first_char == '+' {
                    table_data_start = Some(line_num);
                    keyword_lines.push(line.clone());
                    break;
                }
            }

            keyword_lines.push(line.clone());
        }

        // Parse keywords
        for line in keyword_lines {
            let trimmed = line.trim();
            // Skip comments
            if trimmed.starts_with('#') {
                continue;
            }

            let parts: Vec<&str> = trimmed.split_whitespace().collect();
            if parts.is_empty() {
                continue;
            }

            let keyword = parts[0];

            match keyword {
                "TITLE" => {
                    if has_title {
                        return Err(CubeError::UnknownOrRepeatedKeyword(
                            "TITLE repeated".to_string(),
                        ));
                    }
                    // Extract title between quotes
                    let quote_pos = line.find('"');
                    if quote_pos.is_none() {
                        return Err(CubeError::TitleMissingQuote);
                    }
                    let after_quote = &line[quote_pos.unwrap() + 1..];
                    let end_quote = after_quote.find('"');
                    if end_quote.is_none() {
                        return Err(CubeError::TitleMissingQuote);
                    }
                    title = Some(after_quote[..end_quote.unwrap()].to_string());
                    has_title = true;
                }
                "DOMAIN_MIN" => {
                    if has_domain_min {
                        return Err(CubeError::UnknownOrRepeatedKeyword(
                            "DOMAIN_MIN repeated".to_string(),
                        ));
                    }
                    if parts.len() != 4 {
                        return Err(CubeError::InvalidFormat(
                            "DOMAIN_MIN requires 3 values".to_string(),
                        ));
                    }
                    domain_min = Some([
                        parts[1].parse::<f32>()?,
                        parts[2].parse::<f32>()?,
                        parts[3].parse::<f32>()?,
                    ]);
                    has_domain_min = true;
                }
                "DOMAIN_MAX" => {
                    if has_domain_max {
                        return Err(CubeError::UnknownOrRepeatedKeyword(
                            "DOMAIN_MAX repeated".to_string(),
                        ));
                    }
                    if parts.len() != 4 {
                        return Err(CubeError::InvalidFormat(
                            "DOMAIN_MAX requires 3 values".to_string(),
                        ));
                    }
                    domain_max = Some([
                        parts[1].parse::<f32>()?,
                        parts[2].parse::<f32>()?,
                        parts[3].parse::<f32>()?,
                    ]);
                    has_domain_max = true;
                }
                "LUT_1D_SIZE" => {
                    if has_lut_size {
                        return Err(CubeError::UnknownOrRepeatedKeyword(
                            "LUT size already specified".to_string(),
                        ));
                    }
                    if parts.len() != 2 {
                        return Err(CubeError::InvalidFormat(
                            "LUT_1D_SIZE requires 1 value".to_string(),
                        ));
                    }
                    let size = parts[1].parse::<usize>()?;
                    let lut_1d = Lut1D::new(size)?;
                    lut_data = Some(LutData::Lut1D(lut_1d));
                    lut_size = Some(size);
                    // Set LutType based on whether size is fixed or other
                    lut_type = Some(match size {
                        1024 | 4096 | 16384 | 65536 => LutType::Lut1DFixed,
                        _ => LutType::Lut1DOther,
                    });
                    has_lut_size = true;
                }
                "LUT_3D_SIZE" => {
                    if has_lut_size {
                        return Err(CubeError::UnknownOrRepeatedKeyword(
                            "LUT size already specified".to_string(),
                        ));
                    }
                    if parts.len() != 2 {
                        return Err(CubeError::InvalidFormat(
                            "LUT_3D_SIZE requires 1 value".to_string(),
                        ));
                    }
                    let size = parts[1].parse::<usize>()?;
                    let lut_3d = Lut3D::new(size)?;
                    lut_data = Some(LutData::Lut3D(lut_3d));
                    lut_size = Some(size);
                    // Set LutType based on whether size is fixed or other
                    lut_type = Some(match size {
                        17 | 33 | 65 => LutType::Lut3DFixed,
                        _ => LutType::Lut3DOther,
                    });
                    has_lut_size = true;
                }
                _ => {
                    // Unknown keyword - might be table data
                    if !trimmed.starts_with('#') {
                        // Check if it looks like table data (3 numbers)
                        let num_count = parts.len();
                        if num_count >= 3 {
                            // This is table data, we've reached the data section
                            break;
                        } else {
                            return Err(CubeError::UnknownOrRepeatedKeyword(format!(
                                "Unknown keyword: {}",
                                keyword
                            )));
                        }
                    }
                }
            }
        }

        // Ensure we have a LUT size
        if lut_size.is_none() {
            return Err(CubeError::InvalidFormat(
                "No LUT size specified".to_string(),
            ));
        }

        // Now read table data
        let size = lut_size.unwrap();

        if let Some(LutData::Lut1D(ref mut lut_1d)) = &mut lut_data {
            if let Some(LutType::Lut1DFixed) | Some(LutType::Lut1DOther) = lut_type {
                // Start from where we found first table data line
                let data_start = if let Some(start) = table_data_start {
                    start
                } else {
                    lines.len()
                };

                let data_lines = &lines[data_start..];

                for (idx, line) in data_lines.iter().enumerate() {
                    let trimmed = line.trim();
                    if trimmed.is_empty() || trimmed.starts_with('#') {
                        continue;
                    }

                    let parts: Vec<&str> = trimmed.split_whitespace().collect();
                    if parts.len() < 3 {
                        return Err(CubeError::CouldNotParseTableData(format!(
                            "Expected 3 values, got {}",
                            parts.len()
                        )));
                    }

                    if idx >= size {
                        return Err(CubeError::CouldNotParseTableData(format!(
                            "Too many data lines (expected {})",
                            size
                        )));
                    }

                    let r = parts[0].parse::<f32>()?;
                    let g = parts[1].parse::<f32>()?;
                    let b = parts[2].parse::<f32>()?;

                    lut_1d.set_rgb(idx, [r, g, b])?;
                }

                if lut_1d.size() < size {
                    return Err(CubeError::PrematureEndOfFile);
                }
            }
        } else if let Some(LutData::Lut3D(ref mut lut_3d)) = &mut lut_data {
            if matches!(
                lut_type,
                Some(LutType::Lut3DFixed) | Some(LutType::Lut3DOther)
            ) {
                let data_start = if let Some(start) = table_data_start {
                    start
                } else {
                    lines.len()
                };

                let data_lines = &lines[data_start..];
                let mut point_idx = 0;

                // Read data in order: r changes fastest
                for b in 0..size {
                    for g in 0..size {
                        for r in 0..size {
                            if point_idx >= data_lines.len() {
                                return Err(CubeError::PrematureEndOfFile);
                            }

                            let line = data_lines[point_idx].trim();
                            if line.is_empty() || line.starts_with('#') {
                                return Err(CubeError::CouldNotParseTableData(
                                    "Unexpected comment or empty line in 3D LUT data".to_string(),
                                ));
                            }

                            let parts: Vec<&str> = line.split_whitespace().collect();
                            if parts.len() < 3 {
                                return Err(CubeError::CouldNotParseTableData(format!(
                                    "Expected 3 values, got {}",
                                    parts.len()
                                )));
                            }

                            let out_r = parts[0].parse::<f32>()?;
                            let out_g = parts[1].parse::<f32>()?;
                            let out_b = parts[2].parse::<f32>()?;

                            lut_3d.set_rgb(r, g, b, [out_r, out_g, out_b])?;
                            point_idx += 1;
                        }
                    }
                }
            }
        }

        // Construct final LUT
        let lut = lut_data
            .ok_or_else(|| CubeError::InvalidFormat("LUT data not initialized".to_string()))?;

        let mut cube_lut = match lut {
            LutData::Lut1D(lut_1d) => CubeLut::with_1d(lut_1d),
            LutData::Lut3D(lut_3d) => CubeLut::with_3d(lut_3d),
        };

        // Set optional fields
        cube_lut.title = title;
        cube_lut.domain_min = domain_min.unwrap_or([0.0, 0.0, 0.0]);
        cube_lut.domain_max = domain_max.unwrap_or([1.0, 1.0, 1.0]);

        // Validate LUT
        cube_lut.validate()?;

        Ok(cube_lut)
    }

    /// Parse a CUBE LUT from a file path
    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<CubeLut> {
        let file = std::fs::File::open(path)?;
        Self::parse(file)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_simple_3d_lut() {
        let cube_data = r#"LUT_3D_SIZE 2
0 0 0
1 0 0
0 0.75 0
1 0.75 0
0 0.25 1
1 0.25 1
0 1 1
1 1 1
"#;

        let lut = CubeParser::parse(cube_data.as_bytes()).unwrap();
        assert!(lut.is_3d());
        assert_eq!(lut.lut_3d().unwrap().size(), 2);
    }

    #[test]
    fn test_parse_with_title() {
        let cube_data = r#"TITLE "Test LUT"
LUT_1D_SIZE 3
0 0 0
0.5 1 1.5
1 1 1
"#;

        let lut = CubeParser::parse(cube_data.as_bytes()).unwrap();
        assert_eq!(lut.title.as_ref().unwrap(), "Test LUT");
        assert!(lut.is_1d());
    }

    #[test]
    fn test_parse_with_domain() {
        let cube_data = r#"DOMAIN_MIN 0 0 0
DOMAIN_MAX 1 2 3
LUT_1D_SIZE 3
0 0 0
0.5 1 1.5
1 1 1
"#;

        let lut = CubeParser::parse(cube_data.as_bytes()).unwrap();
        assert_eq!(lut.domain_max, [1.0, 2.0, 3.0]);
    }

    #[test]
    fn test_parse_invalid_size() {
        let cube_data = r#"LUT_3D_SIZE 300
0 0 0
"#;

        let result = CubeParser::parse(cube_data.as_bytes());
        assert!(result.is_err());
    }
}