From 74df9ff84a8bfbc58402a44d24aeab8da4aadfd4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 15 Oct 2026 10:58:26 +0300 Subject: [PATCH] plot styles: the CTB/STB model and codec come from the codec crate Cargo: codec d36331d, kernel 60f086b, graph 74aac56. The table model and its file codec now live in `codec::plot_style` (opencadcodec#53) and are re-exported here. What stays is where tables come from: the plot styles folder, the session registry, the shipped tables, and saving to disk, as the `PlotStyleTableExt` trait. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 +- Cargo.toml | 8 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_save/Cargo.toml | 2 +- crates/ocs_web_worker/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- src/app/commands/fileops.rs | 2 + src/app/control/actions.rs | 2 + src/app/mod.rs | 1 + src/app/update/file.rs | 1 + src/app/update/mod.rs | 1 + src/io/mod.rs | 2 + src/io/pdf_export.rs | 1 + src/io/plot_style.rs | 999 +----------------------------- src/scene/view/render.rs | 1 + 15 files changed, 55 insertions(+), 981 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 180d49ae1..04bd0a5e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4373,7 +4373,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=eaa6d79#eaa6d79155b7364196bb3bfde37a03f2d9cc375f" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d36331d#d36331d9e4f01576cdd29e8420caeb6503675c2c" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4396,7 +4396,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=9c17779#9c17779c9858e96f928166daa21c75aecc7e43e0" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=74aac56#74aac563a09388489834a1cbafc1b3893a739802" dependencies = [ "opencadkernel", "serde_json", @@ -4405,7 +4405,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=bb355ca#bb355ca908dad6dba2e556bc85e3cebbb7e3cb60" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=60f086b#60f086bd6200924f9f96f31de4722d1216bb29b9" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4416,7 +4416,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=bb355ca#bb355ca908dad6dba2e556bc85e3cebbb7e3cb60" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=60f086b#60f086bd6200924f9f96f31de4722d1216bb29b9" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index f64990e5e..0469252a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "eaa6d79", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "bb355ca", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "bb355ca" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "9c17779" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d36331d", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "60f086b", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "60f086b" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "74aac56" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } # Shared SAVE serialization dispatch + web-worker wire format: one # implementation for the inline (fallback/native) path and the worker path. diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 7e247f1b3..dd1f363c3 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "eaa6d79", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d36331d", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "eaa6d79", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d36331d", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_save/Cargo.toml b/crates/ocs_save/Cargo.toml index c3c9ddbd1..19da41e25 100644 --- a/crates/ocs_save/Cargo.toml +++ b/crates/ocs_save/Cargo.toml @@ -10,6 +10,6 @@ publish = false # exactly one implementation. No UI/app dependencies — only the codec + serde. [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "eaa6d79", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d36331d", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 4f04e7327..f6d056778 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "eaa6d79", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d36331d", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index cebb85c28..0bb1cb4b1 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1264,7 +1264,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=eaa6d79#eaa6d79155b7364196bb3bfde37a03f2d9cc375f" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d36331d#d36331d9e4f01576cdd29e8420caeb6503675c2c" dependencies = [ "anyhow", "bitflags", diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 66c951b14..8ab59ffe2 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(target_arch = "wasm32")] +use crate::io::plot_style::PlotStyleTableExt as _; impl OpenCADStudio { pub(super) fn dispatch_fileops(&mut self, cmd: &str, i: usize) -> Option> { diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index aa397d1f4..345ae4af0 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -1,3 +1,5 @@ +#[cfg(not(target_arch = "wasm32"))] +use crate::io::plot_style::PlotStyleTableExt as _; use super::*; use crate::scene::model::object::PropValue; diff --git a/src/app/mod.rs b/src/app/mod.rs index 8e3bebe25..453a0170a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,6 +66,7 @@ pub use settings::MAX_SELECTED_GRIPS; /// as they are; only these two names are reachable externally. pub use settings::{CursorType, IsoPlane}; +use crate::io::plot_style::PlotStyleTableExt as _; use document::DocumentTab; use crate::modules::ModuleEvent; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c04a26005..3e7a744b7 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1,6 +1,7 @@ //! `file` arms and helpers, split out of the original `update.rs` (#mechanical decomposition). #![allow(unused_imports)] +use crate::io::plot_style::PlotStyleTableExt as _; use super::util::*; use super::{format_size, VIEWCUBE_HIT_SIZE}; use crate::app::helpers::{ diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e112cda99..1f330f0d7 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1,3 +1,4 @@ +use crate::io::plot_style::PlotStyleTableExt as _; use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode}; use crate::command::CadCommand; use crate::scene::VIEWCUBE_DRAW_PX; diff --git a/src/io/mod.rs b/src/io/mod.rs index d2ca4be35..4d3deee2f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -51,6 +51,8 @@ pub mod windows_media; pub mod xref; pub mod xref_model; +#[cfg(not(target_arch = "wasm32"))] +use crate::io::plot_style::PlotStyleTableExt as _; use crate::scene::DerivedCaches; use codec::entities::EntityType; // The path-based readers are native-only; web parsing runs through the diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 7b64f1790..791d1bd62 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -2700,6 +2700,7 @@ pub(crate) fn pdf_stream_text(bytes: &[u8]) -> String { #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { + use crate::io::plot_style::PlotStyleTableExt as _; use super::*; fn test_page(wires: Vec) -> PdfPageInput { diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index b5ff78ffd..20d0237c6 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -1,21 +1,16 @@ -//! Plot Style Table — CTB (color-based) and STB (named) file support. -//! -//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties: -//! RGB color override, lineweight, and screening percentage. -//! -//! File format: a fixed 60-byte header followed by zlib-compressed text. -//! -//! STB files follow the same format but use named styles instead of -//! ACI indices; they are read into a `Vec`. +//! Plot style tables in Open CAD Studio: the plot styles folder, the tables +//! registered this session, and the shipped ones. The table model and its +//! CTB/STB codec live in `codec::plot_style`. use rustc_hash::FxHashMap as HashMap; -use std::io::Read; use std::path::{Component, Path}; use std::sync::{Arc, Mutex, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; +pub use codec::plot_style::{decode_plot_style_text, PlotStyleEntry, PlotStyleTable, LW_TABLE}; + pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb"; pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb"; pub const GRAYSCALE_PLOT_STYLE: &str = "Grayscale.ctb"; @@ -181,107 +176,18 @@ fn session_ctb_names() -> Vec { .collect() } -// ── Standard lineweight table (index → mm) ─────────────────────────────────── - -/// Lineweight table: index value → mm, matching the stored 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.45, 0.50, 0.53, 0.60, - 0.65, 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 { - pub name: String, - pub localized_name: String, - 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 the table. 0 (and legacy 255) = object lineweight. - pub lineweight: u8, - /// Screen percentage 0–100 (100 = opaque). - pub screening: u8, - pub color_policy: u8, - pub physical_pen_number: u16, - pub virtual_pen_number: u16, - pub linepattern_size: f32, - pub linetype: u8, - pub adaptive_linetype: bool, - pub fill_style: u8, - pub end_style: u8, - pub join_style: u8, +/// Where Open CAD Studio finds plot style tables: on disk, in this session's +/// registry, and among the shipped ones. +pub trait PlotStyleTableExt: Sized { + fn load(path: &Path) -> Result; + fn builtin(name: &str) -> Result; + fn load_named(name: &str) -> Result; + fn save(&self, path: &Path) -> Result<(), String>; } -impl Default for PlotStyleEntry { - fn default() -> Self { - PlotStyleEntry { - name: String::new(), - localized_name: String::new(), - description: String::new(), - color: None, - lineweight: 0, - screening: 100, - color_policy: 1, - physical_pen_number: 0, - virtual_pen_number: 0, - linepattern_size: 0.5, - linetype: 31, - adaptive_linetype: true, - fill_style: 73, - end_style: 4, - join_style: 5, - } - } -} - -// ── 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, - pub description: String, - pub scale_factor: f32, - pub apply_factor: bool, - pub custom_lineweight_display_units: u8, - pub lineweights: Vec, - /// 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, - /// What had to be tolerated to read the file (a bad checksum, a length - /// that did not add up, descriptions in a Windows code page): the file - /// loaded, but the user deserves to know it was not quite as written. - pub load_warnings: Vec, -} - -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, - description: String::new(), - scale_factor: 1.0, - apply_factor: false, - custom_lineweight_display_units: 0, - lineweights: LW_TABLE.to_vec(), - aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(), - named_entries: HashMap::default(), - load_warnings: Vec::new(), - } - } - +impl PlotStyleTableExt for PlotStyleTable { /// Load a CTB or STB file from disk. - pub fn load(path: &Path) -> Result { + fn load(path: &Path) -> Result { let raw = std::fs::read(path).map_err(|e| e.to_string())?; let name = path .file_name() @@ -291,16 +197,7 @@ impl PlotStyleTable { Self::from_bytes(name, &raw) } - pub fn from_bytes(name: impl Into, raw: &[u8]) -> Result { - let name = name.into(); - let is_stb = name.to_ascii_lowercase().ends_with(".stb"); - let (text, warnings) = decompress_ctb(raw)?; - let mut table = parse_plot_style_text(&text, name, is_stb)?; - table.load_warnings = warnings; - Ok(table) - } - - pub fn builtin(name: &str) -> Result { + fn builtin(name: &str) -> Result { match name.to_ascii_lowercase().as_str() { DEFAULT_PLOT_STYLE => Self::from_bytes( DEFAULT_PLOT_STYLE, @@ -331,24 +228,17 @@ impl PlotStyleTable { } Ok(table) } - "screening 100%.ctb" => Ok(Self::screening(SCREENING_100_PLOT_STYLE, 100)), - "screening 75%.ctb" => Ok(Self::screening(SCREENING_75_PLOT_STYLE, 75)), - "screening 50%.ctb" => Ok(Self::screening(SCREENING_50_PLOT_STYLE, 50)), - "screening 25%.ctb" => Ok(Self::screening(SCREENING_25_PLOT_STYLE, 25)), + "screening 100%.ctb" => Ok(screening(SCREENING_100_PLOT_STYLE, 100)), + "screening 75%.ctb" => Ok(screening(SCREENING_75_PLOT_STYLE, 75)), + "screening 50%.ctb" => Ok(screening(SCREENING_50_PLOT_STYLE, 50)), + "screening 25%.ctb" => Ok(screening(SCREENING_25_PLOT_STYLE, 25)), _ => Err(format!("Unknown built-in plot style: {name}")), } } - fn screening(name: &str, percent: u8) -> Self { - let mut table = Self::identity(name); - for entry in table.aci_entries.iter_mut().skip(1) { - entry.screening = percent; - } - table - } /// Load one CTB by file name from the per-user plot styles folder. - pub fn load_named(name: &str) -> Result { + fn load_named(name: &str) -> Result { let path = Path::new(name); if path.components().count() != 1 || !matches!(path.components().next(), Some(Component::Normal(_))) @@ -407,597 +297,23 @@ impl PlotStyleTable { } /// 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)?; - let mut color = entry - .color - .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); - if entry.color_policy & 2 != 0 { - let rgb = color.or_else(|| { - if aci == 7 { - Some([0.0; 3]) - } else { - codec::types::aci_to_rgb(aci) - .map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) - } - })?; - let gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; - color = Some([gray; 3]); - } - color - } - - /// 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 matches!(entry.lineweight, 0 | 255) { - None - } else { - self.lineweights - .get(entry.lineweight as usize) - .copied() - .map(|weight| { - if self.apply_factor { - weight * self.scale_factor.max(0.0) - } else { - weight - } - }) - } - } - - /// Resolve the screening factor for the given ACI index. - pub fn resolve_screening(&self, aci: u8) -> f32 { - self.aci_entries - .get(aci as usize) - .map(|entry| entry.screening.min(100) as f32 / 100.0) - .unwrap_or(1.0) - } - - // ── Internal serialisation ──────────────────────────────────────────── - - fn to_text(&self) -> String { - let mut s = String::new(); - let description = self.description.replace(['\r', '\n'], " "); - s.push_str(&format!("description=\"{description}\n")); - s.push_str("aci_table_available=TRUE\n"); - // The factor scales every lineweight when applied, so write it in full. - s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor))); - s.push_str(&format!( - "apply_factor={}\n", - if self.apply_factor { "TRUE" } else { "FALSE" } - )); - s.push_str(&format!( - "custom_lineweight_display_units={}\n", - self.custom_lineweight_display_units - )); - s.push_str("aci_table{\n"); - for index in 0..255 { - s.push_str(&format!(" {index}=\"Color_{}\n", index + 1)); - } - s.push_str("}\nplot_style{\n"); - for (index, entry) in self.aci_entries.iter().enumerate().skip(1).take(255) { - let style_index = index - 1; - let style_name = if entry.name.is_empty() { - format!("Color_{index}") - } else { - entry.name.replace(['\r', '\n'], " ") - }; - let localized_name = if entry.localized_name.is_empty() { - style_name.clone() - } else { - entry.localized_name.replace(['\r', '\n'], " ") - }; - let description = entry.description.replace(['\r', '\n'], " "); - s.push_str(&format!(" {style_index}{{\n")); - s.push_str(&format!(" name=\"{style_name}\n")); - s.push_str(&format!(" localized_name=\"{localized_name}\n")); - s.push_str(&format!(" description=\"{description}\n")); - if let Some(rgb) = entry.color { - let packed = packed_rgb(rgb); - s.push_str(&format!(" color={packed}\n mode_color={packed}\n")); - } else { - s.push_str(" color=-1\n"); - } - s.push_str(&format!(" color_policy={}\n", entry.color_policy)); - s.push_str(&format!( - " physical_pen_number={}\n virtual_pen_number={}\n", - entry.physical_pen_number, entry.virtual_pen_number - )); - s.push_str(&format!(" screen={}\n", entry.screening)); - s.push_str(&format!( - " linepattern_size={}\n linetype={}\n adaptive_linetype={}\n", - entry.linepattern_size, - entry.linetype, - if entry.adaptive_linetype { - "TRUE" - } else { - "FALSE" - } - )); - s.push_str(&format!(" lineweight={}\n", entry.lineweight)); - s.push_str(&format!( - " fill_style={}\n end_style={}\n join_style={}\n }}\n", - entry.fill_style, entry.end_style, entry.join_style - )); - } - s.push_str("}\ncustom_lineweight_table{\n"); - for (index, weight) in self.lineweights.iter().enumerate() { - s.push_str(&format!(" {index}={}\n", full_real(*weight))); - } - s.push_str("}\n"); - s + fn save(&self, path: &Path) -> Result<(), String> { + std::fs::write(path, self.to_bytes()?).map_err(|e| e.to_string()) } } -// ── Deflate helpers ─────────────────────────────────────────────────────────── - -/// Decompress a CTB/STB file's raw bytes into the text content, with the -/// things that had to be tolerated on the way. -/// -/// Files written by other applications are read as they are, not as the -/// container says they should be: a checksum or a length that does not add -/// up is reported and read past (the text is either there or the zlib -/// stream fails on its own), a zlib stream with a bad trailer is retried as -/// raw deflate, and text that is not UTF-8 is read as Windows-1252 — the -/// code page those applications write descriptions in. -fn decompress_ctb(data: &[u8]) -> Result<(String, Vec), String> { - const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec"; - let mut warnings = Vec::new(); - let mut decoded; - if data.starts_with(PREFIX) { - if data.len() < 60 { - return Err("CTB header is truncated".into()); - } - let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap()); - let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize; - let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize; - let available = data.len() - 60; - let payload = if compressed_len > available { - warnings.push(format!( - "the header announces {compressed_len} compressed bytes \ - but the file holds {available}" - )); - &data[60..] - } else { - &data[60..60 + compressed_len] - }; - if adler32(payload) != checksum { - warnings.push("the compressed payload's checksum does not match".into()); - } - decoded = inflate_lenient(payload, &mut warnings)?; - // A byte or a few short is a header that counted its trailing NUL - // differently; far short of the announced text is a cut-off file, - // which the inflater does not always notice on its own. - const TOLERATED_SHORTFALL: usize = 16; - if decoded.len() + TOLERATED_SHORTFALL < text_len { - return Err(format!( - "CTB text is incomplete: the header announces {text_len} bytes \ - but only {} could be read", - decoded.len() - )); - } - if decoded.len() != text_len { - warnings.push(format!( - "the header announces {text_len} bytes of text but {} were read", - decoded.len() - )); - } - } else { - let split_at = data - .iter() - .position(|&b| b == b'\n') - .map(|p| p + 1) - .unwrap_or(0); - let payload = &data[split_at..]; - if payload.starts_with(&[0x78]) { - decoded = inflate_lenient(payload, &mut warnings)?; - } else { - use flate2::read::DeflateDecoder; - decoded = inflate_capped( - DeflateDecoder::new(payload), - "legacy CTB deflate decompress", - )?; - } - } - while decoded.last() == Some(&0) { - decoded.pop(); - } - let (text, code_page) = decode_plot_style_text(decoded); - if let Some(note) = code_page { - warnings.push(note); - } - Ok((text, warnings)) -} - -/// Far above any real plot-style table (a CTB is a text config file), so a -/// crafted few-hundred-KB payload cannot inflate until memory runs out — -/// flate2's readers have no output budget of their own. Same idea as the -/// `STREAM_LIMIT` in `dgn8.rs`. -const MAX_INFLATED_BYTES: u64 = 64 * 1024 * 1024; - -/// One decompression with a hard output budget: `read_to_end` grows its -/// `Vec` without bound, so the reader is capped and a payload that reaches -/// past the cap is refused — an allocation failure is an *abort*, not a -/// catchable panic. The reader is allowed one byte past the cap: -/// `take(cap)` stops *at* the cap, which cannot distinguish "exactly cap" -/// (within the contract — "exceeds means exceeds") from "at least cap", -/// so `cap + 1` makes the boundary unambiguous. -fn inflate_capped(reader: impl Read, what: &str) -> Result, String> { - let mut out = Vec::new(); - let read = reader - .take(MAX_INFLATED_BYTES + 1) - .read_to_end(&mut out) - .map_err(|e| format!("{what}: {e}"))?; - if read as u64 > MAX_INFLATED_BYTES { - return Err(format!( - "{what}: inflated data exceeds the {MAX_INFLATED_BYTES}-byte limit" - )); - } - Ok(out) -} - -/// Inflate a zlib stream, falling back to the raw deflate data behind its -/// two-byte header when the stream's own trailer is wrong. -fn inflate_lenient(payload: &[u8], warnings: &mut Vec) -> Result, String> { - use flate2::read::{DeflateDecoder, ZlibDecoder}; - match inflate_capped(ZlibDecoder::new(payload), "CTB zlib decompress") { - Ok(decoded) => Ok(decoded), - Err(zlib_error) if payload.len() > 2 => { - let raw = inflate_capped(DeflateDecoder::new(&payload[2..]), "CTB zlib decompress") - .map_err(|_| zlib_error)?; - warnings.push("the zlib stream is damaged; its deflate data was read directly".into()); - Ok(raw) - } - Err(zlib_error) => Err(zlib_error), - } -} - -/// The table's text out of its bytes: UTF-8 when it is, else Windows-1252 -/// (Latin-1 with the 0x80–0x9F block the code page defines), which is what -/// applications on Western systems write into descriptions. Other code -/// pages come out as readable but wrong characters in descriptions only — -/// pens and colours are numbers and unaffected. The second value says when -/// the fallback was taken. -pub fn decode_plot_style_text(bytes: Vec) -> (String, Option) { - match String::from_utf8(bytes) { - Ok(text) => (text, None), - Err(error) => { - let text = error - .into_bytes() - .into_iter() - .map(windows_1252_char) - .collect(); - ( - text, - Some("descriptions were read as Windows-1252 text (the file is not UTF-8)".into()), - ) - } - } -} - -/// One Windows-1252 byte as a character: Latin-1 except for the 0x80–0x9F -/// block, which the code page fills with punctuation and accented letters. -fn windows_1252_char(byte: u8) -> char { - const HIGH: [char; 32] = [ - '\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', - '\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}', - '\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', - '\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', - '\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}', - ]; - match byte { - 0x80..=0x9F => HIGH[usize::from(byte - 0x80)], - _ => char::from(byte), - } -} - -/// 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; - const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec"; - let mut body = text.to_vec(); - body.push(0); - let mut enc = ZlibEncoder::new(Vec::new(), Compression::default()); - enc.write_all(&body).map_err(|e| e.to_string())?; - let compressed = enc.finish().map_err(|e| e.to_string())?; - let mut out = PREFIX.to_vec(); - out.extend_from_slice(&adler32(&compressed).to_le_bytes()); - out.extend_from_slice(&(body.len() as u32).to_le_bytes()); - out.extend_from_slice(&(compressed.len() as u32).to_le_bytes()); - out.extend_from_slice(&compressed); - Ok(out) -} - -fn adler32(bytes: &[u8]) -> u32 { - const MOD: u32 = 65_521; - let mut a = 1u32; - let mut b = 0u32; - for byte in bytes { - a = (a + u32::from(*byte)) % MOD; - b = (b + a) % MOD; - } - (b << 16) | a -} - -/// A real number written in full, keeping the decimal point on a whole value. -fn full_real(value: f32) -> String { - let mut text = value.to_string(); - if text.chars().all(|c| c.is_ascii_digit() || c == '-') { - text.push_str(".0"); - } - text -} - -fn packed_rgb([r, g, b]: [u8; 3]) -> i32 { - u32::from_be_bytes([0xC2, r, g, b]) as i32 -} - -// ── Text parser ─────────────────────────────────────────────────────────────── - -fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result { - if text.lines().any(|line| line.trim() == "begin_plot_style") { - return parse_legacy_plot_style_text(text, name, is_stb); - } - - #[derive(Default)] - struct PendingStyle { - index: usize, - name: String, - entry: PlotStyleEntry, - color: Option, - mode_color: Option, - } - - #[derive(Clone, Copy, PartialEq, Eq)] - enum Section { - Root, - Other, - PlotStyles, - Lineweights, - } - - let mut aci_entries: Vec = - (0..=255).map(|_| PlotStyleEntry::default()).collect(); - let mut named_entries: HashMap = HashMap::default(); - let mut description = String::new(); - let mut scale_factor = 1.0f32; - let mut apply_factor = false; - let mut custom_lineweight_display_units = 0u8; - let mut lineweights = Vec::::new(); - let mut section = Section::Root; - let mut current: Option = None; - - for line in text.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if line == "plot_style{" { - section = Section::PlotStyles; - continue; - } - if line == "custom_lineweight_table{" { - section = Section::Lineweights; - continue; - } - if line.ends_with('{') { - if section == Section::PlotStyles && current.is_none() { - if let Ok(index) = line.trim_end_matches('{').trim().parse::() { - let default_name = format!("Color_{}", index + 1); - let mut style = PendingStyle { - index, - name: default_name.clone(), - ..Default::default() - }; - style.entry.name = default_name.clone(); - style.entry.localized_name = default_name; - current = Some(style); - continue; - } - } - section = Section::Other; - continue; - } - if line == "}" { - if let Some(mut style) = current.take() { - let packed = style.mode_color.or(style.color); - style.entry.color = packed.and_then(unpack_plot_color); - if is_stb { - named_entries.insert(style.name, style.entry); - } else if style.index < 255 { - aci_entries[style.index + 1] = style.entry; - } - } else { - section = Section::Root; - } - continue; - } - - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - let value = value.trim().trim_start_matches('"'); - if let Some(style) = current.as_mut() { - match key { - "name" => { - style.name = value.to_string(); - style.entry.name = value.to_string(); - } - "localized_name" => style.entry.localized_name = value.to_string(), - "description" => { - style.entry.description = value.to_string(); - } - "screen" => { - if let Ok(v) = value.parse::() { - style.entry.screening = v.min(100); - } - } - "lineweight" => { - if let Ok(v) = value.parse::() { - style.entry.lineweight = v; - } - } - "color" => style.color = value.parse::().ok(), - "mode_color" => style.mode_color = value.parse::().ok(), - "color_policy" => style.entry.color_policy = value.parse().unwrap_or(1), - "physical_pen_number" => { - style.entry.physical_pen_number = value.parse().unwrap_or(0) - } - "virtual_pen_number" => style.entry.virtual_pen_number = value.parse().unwrap_or(0), - "linepattern_size" => style.entry.linepattern_size = value.parse().unwrap_or(0.5), - "linetype" => style.entry.linetype = value.parse().unwrap_or(31), - "adaptive_linetype" => { - style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE") - } - "fill_style" => style.entry.fill_style = value.parse().unwrap_or(73), - "end_style" => style.entry.end_style = value.parse().unwrap_or(4), - "join_style" => style.entry.join_style = value.parse().unwrap_or(5), - _ => {} - } - continue; - } - - match section { - Section::Root => match key { - "description" => description = value.to_string(), - "scale_factor" => scale_factor = value.parse().unwrap_or(1.0), - "apply_factor" => apply_factor = value.eq_ignore_ascii_case("TRUE"), - "custom_lineweight_display_units" => { - custom_lineweight_display_units = value.parse().unwrap_or(0) - } - _ => {} - }, - Section::Lineweights => { - // The key is file-controlled: `18446744073709551615` made - // `index + 1` overflow (debug panic, release wrap to - // `resize(0)` then OOB), and `500000000` resized a 2 GB - // table. Entries past 255 are unreachable — the consumer - // indexes with `PlotStyleEntry.lineweight: u8` — so - // oversized keys are ignored like other malformed lines. - if let (Ok(index), Ok(weight)) = (key.parse::(), value.parse::()) { - if index <= u8::MAX as usize { - if lineweights.len() <= index { - lineweights.resize(index + 1, 0.0); - } - lineweights[index] = weight; - } - } - } - _ => {} - } - } - - if lineweights.is_empty() { - lineweights = LW_TABLE.to_vec(); - } - - Ok(PlotStyleTable { - name, - is_stb, - description, - scale_factor, - apply_factor, - custom_lineweight_display_units, - lineweights, - aci_entries, - named_entries, - load_warnings: Vec::new(), - }) -} - -fn unpack_plot_color(packed: i32) -> Option<[u8; 3]> { - if matches!(packed, -1 | -1_006_632_961 | -1_056_964_608) { - return None; - } - let bytes = (packed as u32).to_be_bytes(); - Some([bytes[1], bytes[2], bytes[3]]) -} - -fn parse_legacy_plot_style_text( - text: &str, - name: String, - is_stb: bool, -) -> Result { +/// A table that screens every color to `percent`. +fn screening(name: &str, percent: u8) -> PlotStyleTable { let mut table = PlotStyleTable::identity(name); - table.is_stb = is_stb; - let mut style_index = 1usize; - let mut current: Option = None; - let mut current_name = String::new(); - for line in text.lines().map(str::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 { - table.named_entries.insert(current_name.clone(), entry); - } else if style_index <= 255 { - table.aci_entries[style_index] = entry; - style_index += 1; - } - } - continue; - } - let Some(entry) = current.as_mut() else { - continue; - }; - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let value = value.trim(); - match key.trim() { - "description" => { - entry.description = value.to_string(); - if !value.is_empty() { - current_name = value.to_string(); - } - } - "screen" => entry.screening = value.parse::().unwrap_or(100).min(100), - "lineweight" => entry.lineweight = value.parse().unwrap_or(0), - "color1" if value.starts_with('#') && value.len() == 7 => { - // The guard is a BYTE length, so `#aébé` (also 7 bytes) - // reaches fixed-offset slices that split a character. - // `str::get` is None off a char boundary; the `unwrap_or(0)` - // path is the same one unparseable pairs always took. - let pair = |range: std::ops::Range| { - value - .get(range) - .and_then(|hex| u8::from_str_radix(hex, 16).ok()) - .unwrap_or(0) - }; - entry.color = Some([pair(1..3), pair(3..5), pair(5..7)]); - } - "color1" => entry.color = value.parse::().ok().and_then(unpack_plot_color), - _ => {} - } + for entry in table.aci_entries.iter_mut().skip(1) { + entry.screening = percent; } - Ok(table) + table } #[cfg(test)] mod round_trip_tests { - use super::{compress_ctb, PlotStyleTable}; - - /// What SAVE writes, read back the way the Plot dialog loads it. - fn saved_and_reloaded(table: &PlotStyleTable) -> PlotStyleTable { - let bytes = compress_ctb(table.to_text().as_bytes()).expect("compress"); - PlotStyleTable::from_bytes(table.name.clone(), &bytes).expect("reload") - } + use super::{PlotStyleTable, PlotStyleTableExt}; /// A table registered this session (the web stand-in for the plot-styles /// folder) is what `load_named` returns for its name — matched @@ -1006,7 +322,7 @@ mod round_trip_tests { fn load_named_resolves_a_session_registered_table() { let mut table = PlotStyleTable::identity("SessionPens.ctb"); table.aci_entries[1].lineweight = 13; // 0.50 mm - let bytes = compress_ctb(table.to_text().as_bytes()).expect("compress"); + let bytes = table.to_bytes().expect("encode"); super::register_session_table("SessionPens.ctb", std::sync::Arc::new(bytes)); let loaded = PlotStyleTable::load_named("sessionpens.CTB").expect("registry hit"); @@ -1021,8 +337,7 @@ mod round_trip_tests { /// tables, but never a named (.stb) one. #[test] fn available_names_include_session_registered_tables() { - let bytes = - compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + let bytes = PlotStyleTable::identity("x").to_bytes().expect("encode"); let bytes = std::sync::Arc::new(bytes); super::register_session_table("DialogPens.ctb", std::sync::Arc::clone(&bytes)); super::register_session_table("Named.stb", bytes); @@ -1040,126 +355,11 @@ mod round_trip_tests { "{names:?}" ); } - - /// Saving a table keeps its global scale factor, which scales every - /// lineweight while apply_factor is on. It used to be cut to one decimal. - #[test] - fn the_scale_factor_survives_a_save() { - let mut table = PlotStyleTable::identity("scaled.ctb"); - table.apply_factor = true; - table.scale_factor = 0.25; - table.aci_entries[1].lineweight = 13; // 0.50 mm - let reloaded = saved_and_reloaded(&table); - assert_eq!(reloaded.scale_factor, 0.25); - assert_eq!(reloaded.resolve_lineweight(1), Some(0.125)); - } - - /// A whole factor is still written with its decimal point. - #[test] - fn a_whole_scale_factor_keeps_its_decimal_point() { - let table = PlotStyleTable::identity("plain.ctb"); - assert!(table.to_text().contains("\nscale_factor=1.0\n")); - assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0); - } - - /// Saving keeps each custom lineweight as the table holds it. They used to - /// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one. - #[test] - fn custom_lineweights_survive_a_save() { - let mut table = PlotStyleTable::identity("pens.ctb"); - table.lineweights[1] = 0.035; - table.aci_entries[1].lineweight = 1; - let reloaded = saved_and_reloaded(&table); - assert_eq!(reloaded.lineweights, table.lineweights); - assert_eq!(reloaded.resolve_lineweight(1), Some(0.035)); - } } #[cfg(test)] mod lenient_loading_tests { - use super::{compress_ctb, decode_plot_style_text, PlotStyleTable}; - - fn table_with_description(description: &str) -> PlotStyleTable { - let mut table = PlotStyleTable::identity("foreign.ctb"); - table.description = description.to_string(); - table.aci_entries[1].lineweight = 13; - table - } - - fn compressed(table: &PlotStyleTable) -> Vec { - compress_ctb(table.to_text().as_bytes()).expect("compress") - } - - /// The pen data must survive every tolerated defect. - fn assert_pens_kept(table: &PlotStyleTable) { - assert_eq!(table.aci_entries[1].lineweight, 13); - } - - #[test] - fn a_wrong_checksum_is_reported_not_fatal() { - let mut bytes = compressed(&table_with_description("ok")); - bytes[48] ^= 0xFF; - let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); - assert_pens_kept(&table); - assert_eq!(table.load_warnings.len(), 1, "{:?}", table.load_warnings); - assert!(table.load_warnings[0].contains("checksum")); - } - - #[test] - fn a_text_length_off_by_one_and_a_short_payload_field_are_tolerated() { - let mut bytes = compressed(&table_with_description("ok")); - let text_len = u32::from_le_bytes(bytes[52..56].try_into().unwrap()); - bytes[52..56].copy_from_slice(&(text_len - 1).to_le_bytes()); - let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); - assert_pens_kept(&table); - assert!(table - .load_warnings - .iter() - .any(|w| w.contains("bytes of text"))); - - let mut bytes = compressed(&table_with_description("ok")); - let compressed_len = u32::from_le_bytes(bytes[56..60].try_into().unwrap()); - bytes[56..60].copy_from_slice(&(compressed_len + 100).to_le_bytes()); - let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads"); - assert_pens_kept(&table); - assert!(table - .load_warnings - .iter() - .any(|w| w.contains("compressed bytes"))); - } - - #[test] - fn windows_1252_descriptions_are_read_with_a_note() { - // "Descripción" and "Ελληνικά" are not what a Western file holds; a - // Western one holds e.g. "Plumas – señal" in Windows-1252. - let text = table_with_description("marker").to_text(); - let mut bytes = text.into_bytes(); - let marker = bytes.windows(6).position(|w| w == b"marker").unwrap(); - let cp1252 = [ - 0x50, 0x6C, 0x75, 0x6D, 0x61, 0x73, 0x20, 0x96, 0x20, 0x73, 0x65, 0xF1, 0x61, 0x6C, - ]; - bytes.splice(marker..marker + 6, cp1252); - let compressed = compress_ctb(&bytes).unwrap(); - let table = PlotStyleTable::from_bytes("foreign.ctb", &compressed).expect("loads"); - assert_pens_kept(&table); - assert_eq!(table.description, "Plumas – señal"); - assert!(table - .load_warnings - .iter() - .any(|w| w.contains("Windows-1252"))); - // Clean UTF-8 gets no note. - let (text, note) = decode_plot_style_text("Ελληνικά".as_bytes().to_vec()); - assert_eq!(text, "Ελληνικά"); - assert!(note.is_none()); - } - - #[test] - fn a_truncated_stream_is_still_an_error() { - let bytes = compressed(&table_with_description("ok")); - let cut = &bytes[..bytes.len() / 2]; - let error = PlotStyleTable::from_bytes("foreign.ctb", cut).unwrap_err(); - assert!(error.contains("incomplete"), "{error}"); - } + use super::{PlotStyleTable, PlotStyleTableExt}; #[test] fn named_tables_and_missing_files_get_a_reason() { @@ -1171,140 +371,3 @@ mod lenient_loading_tests { } } -#[cfg(test)] -mod inflate_limit_tests { - use super::{decompress_ctb, MAX_INFLATED_BYTES}; - use std::io::Write; - - /// A payload that lands exactly on the cap is within the contract - /// ("exceeds means exceeds"): it must load, not be refused. `take(cap)` - /// alone can't tell "exactly cap" from "at least cap" — the reader has - /// to be allowed one byte past the limit. - #[test] - fn an_inflate_of_exactly_the_cap_is_accepted() { - let mut encoder = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder - .write_all(&vec![b' '; MAX_INFLATED_BYTES as usize]) - .expect("compress"); - let payload = encoder.finish().expect("finish"); - let mut data = vec![b'\n']; - data.extend(payload); - let out = decompress_ctb(&data).expect("exactly the cap must be accepted"); - assert_eq!(out.0.len() as u64, MAX_INFLATED_BYTES); - } - - /// One byte over the cap is refused with the same limit error the - /// oversized bombs produce. - #[test] - fn an_inflate_one_byte_over_the_cap_is_refused() { - let mut encoder = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder - .write_all(&vec![b' '; (MAX_INFLATED_BYTES + 1) as usize]) - .expect("compress"); - let payload = encoder.finish().expect("finish"); - let mut data = vec![b'\n']; - data.extend(payload); - let error = decompress_ctb(&data).expect_err("one byte over must be refused"); - assert!(error.contains("limit"), "{error}"); - } - - /// Twice the 64 MiB inflate cap the fix introduces — a payload that is - /// tiny compressed (spaces) but must not be inflated whole. - fn bomb_plaintext() -> Vec { - vec![b' '; 128 * 1024 * 1024] - } - - /// A crafted CTB is a few hundred KB of zlib that expands to hundreds of - /// MB; `read_to_end` has no output budget, so the `Vec` grows until the - /// allocator fails and the process **aborts** (an abort is not a - /// catchable panic). Both the zlib path and the legacy raw-deflate path - /// must stop at the cap instead. - #[test] - fn a_zlib_bomb_is_refused_at_the_inflate_limit() { - let mut encoder = - flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&bomb_plaintext()).expect("compress"); - let payload = encoder.finish().expect("finish"); - assert_eq!(payload[0], 0x78, "zlib header for the inflate_lenient path"); - - let mut data = vec![b'\n']; - data.extend(payload); - let error = decompress_ctb(&data).expect_err("zlib bomb must be refused"); - assert!(error.contains("limit"), "{error}"); - } - - #[test] - fn a_legacy_deflate_bomb_is_refused_at_the_inflate_limit() { - let mut encoder = - flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&bomb_plaintext()).expect("compress"); - let payload = encoder.finish().expect("finish"); - assert_ne!( - payload[0], 0x78, - "raw deflate takes the legacy DeflateDecoder path" - ); - - let mut data = vec![b'\n']; - data.extend(payload); - let error = decompress_ctb(&data).expect_err("deflate bomb must be refused"); - assert!(error.contains("limit"), "{error}"); - } -} - -#[cfg(test)] -mod lineweight_table_tests { - use super::{parse_plot_style_text, LW_TABLE}; - - fn parse(text: &str) -> super::PlotStyleTable { - parse_plot_style_text(text, "crafted.ctb".into(), false).expect("parses") - } - - /// The table key is file-controlled: `18446744073709551615` made - /// `index + 1` overflow — debug builds panic, release wraps to - /// `resize(0)` and then panics OOB at `lineweights[usize::MAX]` — and - /// `500000000` resized a 2 GB table (allocation abort). Entries past - /// 255 are unreachable anyway: `PlotStyleEntry.lineweight` is a `u8`. - #[test] - fn oversized_lineweight_table_keys_are_ignored() { - let table = parse("custom_lineweight_table{\n18446744073709551615=0.5\n}\n"); - assert_eq!(table.lineweights, LW_TABLE.to_vec()); - } - - /// In-range keys still extend the table; 256 (beyond `u8`) is ignored. - #[test] - fn in_range_lineweight_table_keys_are_kept() { - let table = parse("custom_lineweight_table{\n30=0.7\n}\n"); - assert_eq!(table.lineweights.len(), 31); - assert_eq!(table.lineweights[30], 0.7); - - let table = parse("custom_lineweight_table{\n256=0.7\n}\n"); - assert_eq!(table.lineweights, LW_TABLE.to_vec()); - } -} - -#[cfg(test)] -mod legacy_color_tests { - use super::parse_legacy_plot_style_text; - - /// `color1` guards on `starts_with('#') && len() == 7` — a BYTE length — - /// then slices fixed byte offsets. `#aébé` is also 7 bytes, and byte 3 - /// splits the second `é`: loading a crafted legacy CTB panicked with - /// `byte index 3 is not a char boundary`. - #[test] - fn a_multibyte_color1_value_does_not_panic() { - let text = "begin_plot_style\ncolor1 = #aébé\nend_plot_style\n"; - let table = parse_legacy_plot_style_text(text, "evil.ctb".into(), false).expect("parses"); - // Non-hex pairs stay `unwrap_or(0)` — [0, 0, 0], never a panic. - assert_eq!(table.aci_entries[1].color, Some([0, 0, 0])); - } - - /// Well-formed ASCII `#RRGGBB` keeps parsing byte-for-byte. - #[test] - fn a_hex_color1_value_still_parses() { - let text = "begin_plot_style\ncolor1 = #A1B2C3\nend_plot_style\n"; - let table = parse_legacy_plot_style_text(text, "ok.ctb".into(), false).expect("parses"); - assert_eq!(table.aci_entries[1].color, Some([0xa1, 0xb2, 0xc3])); - } -} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 1948b0191..c42d93184 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1,6 +1,7 @@ // GPU rendering primitives, shader::Program / shader::Primitive impls, // and entity render-style helpers for the Scene. +use crate::io::plot_style::PlotStyleTableExt as _; use codec::tables::LineType; use codec::types::{Color as AcadColor, LineWeight}; use codec::{CadDocument, EntityType, Handle};