//! Plot Style Table — CTB (color-based) and STB (named) file support. //! //! CTB files map AutoCAD Color Index (ACI, 1-255) to pen properties: //! RGB color override, lineweight, and screeing percentage. //! //! File format: deflate-compressed text (key = value pairs) with //! 255 `begin_plot_style … end_plot_style` blocks. //! //! STB files follow the same format but use named styles instead of //! ACI indices; they are read into a `Vec`. use rustc_hash::FxHashMap as HashMap; use std::io::Read; use std::path::Path; // ── AutoCAD standard lineweight table (index → mm) ─────────────────────────── /// Lineweight table: index value → mm, matching AutoCAD's LWEIGHT codes. /// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum. pub const LW_TABLE: &[f32] = &[ 0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.53, 0.60, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11, ]; // ── Per-color entry ─────────────────────────────────────────────────────────── /// A single entry in a CTB or STB plot style table. #[derive(Debug, Clone)] pub struct PlotStyleEntry { /// Optional display name (empty for CTB; the style name for STB). pub description: String, /// If `Some([r,g,b])`, override the entity color with this RGB value (0..255). /// If `None`, use the object color. pub color: Option<[u8; 3]>, /// Lineweight index into `LW_TABLE`. 255 = use object lineweight. pub lineweight: u8, /// Screen percentage 0–100 (100 = opaque). pub screening: u8, } impl Default for PlotStyleEntry { fn default() -> Self { PlotStyleEntry { description: String::new(), color: None, lineweight: 255, // use object lineweight screening: 100, } } } // ── Plot Style Table ────────────────────────────────────────────────────────── /// A loaded CTB or STB plot style table. #[derive(Debug, Clone)] #[allow(dead_code)] pub struct PlotStyleTable { /// File name (without path), e.g. "monochrome.ctb". pub name: String, /// Whether this is a named-style (STB) table rather than color-based (CTB). pub is_stb: bool, /// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid). pub aci_entries: Vec, // 256 entries, index = ACI /// For STB: named style entries. pub named_entries: HashMap, } impl PlotStyleTable { /// Create an identity CTB table (no overrides for any color). pub fn identity(name: impl Into) -> Self { PlotStyleTable { name: name.into(), is_stb: false, aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(), named_entries: HashMap::default(), } } /// Load a CTB or STB file from disk. pub fn load(path: &Path) -> Result { let raw = std::fs::read(path).map_err(|e| e.to_string())?; let name = path .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); let is_stb = name.to_lowercase().ends_with(".stb"); let text = decompress_ctb(&raw)?; parse_plot_style_text(&text, name, is_stb) } /// Write this table to disk as a CTB/STB file. #[allow(dead_code)] pub fn save(&self, path: &Path) -> Result<(), String> { let text = self.to_text(); let compressed = compress_ctb(text.as_bytes())?; std::fs::write(path, compressed).map_err(|e| e.to_string()) } /// Resolve the effective print RGB color for the given ACI index. /// Returns None if no override (use object color). pub fn resolve_color(&self, aci: u8) -> Option<[f32; 3]> { let entry = self.aci_entries.get(aci as usize)?; entry .color .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) } /// Resolve the effective lineweight in mm for the given ACI index. /// Returns None if no override (use object lineweight). pub fn resolve_lineweight(&self, aci: u8) -> Option { let entry = self.aci_entries.get(aci as usize)?; if entry.lineweight == 255 { None } else { LW_TABLE.get(entry.lineweight as usize).copied() } } // ── Internal serialisation ──────────────────────────────────────────── fn to_text(&self) -> String { let mut s = String::new(); s.push_str("description=\n"); s.push_str("apply_factor=0\n"); s.push_str("unit_type=1\n"); s.push_str("custom_lineweight_display_units=0\n"); for (_idx, entry) in self.aci_entries.iter().enumerate().skip(1) { s.push_str("begin_plot_style\n"); s.push_str(&format!(" description={}\n", entry.description)); s.push_str(" physical_pen_number=0\n"); s.push_str(" virtual_pen_number=0\n"); s.push_str(&format!(" screen={}\n", entry.screening)); s.push_str(" linepattern_size=0.5\n"); s.push_str(" linetype=31\n"); s.push_str(" adaptive_linetype=TRUE\n"); s.push_str(&format!(" lineweight={}\n", entry.lineweight)); s.push_str(" fill_style=64\n"); s.push_str(" end_style=0\n"); s.push_str(" join_style=0\n"); if let Some([r, g, b]) = entry.color { s.push_str(&format!(" color1=#{:02X}{:02X}{:02X}\n", r, g, b)); } else { // 0xC2000000 = "use object color" s.push_str(" color1=-1056964608\n"); } s.push_str("end_plot_style\n"); } s } } // ── Deflate helpers ─────────────────────────────────────────────────────────── /// Decompress a CTB/STB file's raw bytes into the text content. /// /// CTB files start with a plain-text header (first line: "PIAFILEVERSION_2.0") /// followed by raw-deflate compressed content. Some tools write pure zlib /// (with the two-byte zlib header 0x78 0x9C) instead — we handle both. fn decompress_ctb(data: &[u8]) -> Result { // Find the first newline — everything after it is the compressed payload. let split_at = data .iter() .position(|&b| b == b'\n') .map(|p| p + 1) .unwrap_or(0); let payload = &data[split_at..]; // Try zlib (0x78 prefix) first, then raw deflate. let mut text = String::new(); if payload.starts_with(&[0x78]) { use flate2::read::ZlibDecoder; ZlibDecoder::new(payload) .read_to_string(&mut text) .map_err(|e| format!("zlib decompress: {e}"))?; } else { use flate2::read::DeflateDecoder; DeflateDecoder::new(payload) .read_to_string(&mut text) .map_err(|e| format!("deflate decompress: {e}"))?; } Ok(text) } /// Compress plot-style text content as a CTB/STB file. fn compress_ctb(text: &[u8]) -> Result, String> { use flate2::{write::ZlibEncoder, Compression}; use std::io::Write; let header = b"PIAFILEVERSION_2.0\r\n"; let mut compressed: Vec = Vec::new(); { let mut enc = ZlibEncoder::new(&mut compressed, Compression::default()); enc.write_all(text).map_err(|e| e.to_string())?; } let mut out = header.to_vec(); out.extend_from_slice(&compressed); Ok(out) } // ── Text parser ─────────────────────────────────────────────────────────────── fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result { let mut aci_entries: Vec = (0..=255).map(|_| PlotStyleEntry::default()).collect(); let mut named_entries: HashMap = HashMap::default(); let mut style_index: usize = 1; // CTB: 1-based ACI index let mut current: Option = None; let mut current_name: String = String::new(); for line in text.lines() { let line = line.trim(); if line == "begin_plot_style" { current = Some(PlotStyleEntry::default()); current_name = format!("Color_{}", style_index); continue; } if line == "end_plot_style" { if let Some(entry) = current.take() { if is_stb { named_entries.insert(current_name.clone(), entry); } else if style_index <= 255 { aci_entries[style_index] = entry; style_index += 1; } } continue; } let Some(entry) = current.as_mut() else { continue; }; if let Some((key, val)) = line.split_once('=') { let key = key.trim(); let val = val.trim(); match key { "description" => { if !val.is_empty() { entry.description = val.to_string(); current_name = val.to_string(); } } "screen" => { if let Ok(v) = val.parse::() { entry.screening = v; } } "lineweight" => { if let Ok(v) = val.parse::() { entry.lineweight = v; } } "color1" => { if val.starts_with('#') && val.len() == 7 { // #RRGGBB let r = u8::from_str_radix(&val[1..3], 16).unwrap_or(0); let g = u8::from_str_radix(&val[3..5], 16).unwrap_or(0); let b = u8::from_str_radix(&val[5..7], 16).unwrap_or(0); entry.color = Some([r, g, b]); } else if let Ok(packed) = val.parse::() { // AutoCAD packs RGB as 0xC0RRGGBB negative int. // Value 0xC2000000 (-1056964608) = use object color. if packed != -1056964608i32 { let u = packed as u32; let r = ((u >> 16) & 0xFF) as u8; let g = ((u >> 8) & 0xFF) as u8; let b = (u & 0xFF) as u8; entry.color = Some([r, g, b]); } } } _ => {} } } } Ok(PlotStyleTable { name, is_stb, aci_entries, named_entries, }) }