Files
OneCAD-Draft/src/io/plot_style.rs
T
Karim Jerbi 790da218b5 fix(plot-style): bound custom lineweight table keys to u8 range
Bug:
Custom lineweight table entries in a CTB are keyed by a number parsed
from the file and stored without an upper bound, but the only reader
(resolve_lineweight) indexes through entry.lineweight: u8. A key beyond
u8::MAX (e.g. 18446744073709551615 in a crafted file) caused an
out-of-bounds-style failure or a multi-GB table allocation when the
style was applied.

Reproduce:
1. Craft a CTB whose custom lineweight table uses a huge key
   (usize::MAX).
2. Apply the style or open plot settings.
3. Before: overflow panic (or a giant allocation).
4. After: the key is out of range, the line is ignored like any other
   malformed CTB line, and the style loads.

Fix:
Keys are bounded to u8::MAX when parsed - exactly the range the
u8-typed reader can ever use. In-range keys behave as before; keys 256
and above are dropped. Tests: a usize::MAX key does not overflow, an
in-range key is kept and applied, 256 is ignored.
2026-09-30 19:15:49 +01:00

1130 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<NamedPlotStyle>`.
use rustc_hash::FxHashMap as HashMap;
use std::io::Read;
use std::path::{Component, Path};
#[cfg(not(target_arch = "wasm32"))]
use std::path::PathBuf;
pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb";
pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb";
pub const GRAYSCALE_PLOT_STYLE: &str = "Grayscale.ctb";
pub const FILL_PATTERNS_PLOT_STYLE: &str = "Fill Patterns.ctb";
pub const SCREENING_100_PLOT_STYLE: &str = "Screening 100%.ctb";
pub const SCREENING_75_PLOT_STYLE: &str = "Screening 75%.ctb";
pub const SCREENING_50_PLOT_STYLE: &str = "Screening 50%.ctb";
pub const SCREENING_25_PLOT_STYLE: &str = "Screening 25%.ctb";
const STANDARD_PLOT_STYLES: &[&str] = &[
GRAYSCALE_PLOT_STYLE,
FILL_PATTERNS_PLOT_STYLE,
SCREENING_100_PLOT_STYLE,
SCREENING_75_PLOT_STYLE,
SCREENING_50_PLOT_STYLE,
SCREENING_25_PLOT_STYLE,
];
#[cfg(not(target_arch = "wasm32"))]
pub fn plot_styles_dir() -> Result<PathBuf, String> {
crate::config::config_dir()
.map(|path| path.join("plotstyles"))
.ok_or_else(|| "Plot styles folder could not be resolved".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn ensure_plot_styles_dir() -> Result<PathBuf, String> {
let dir = plot_styles_dir()?;
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
for (name, bytes) in [
(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb").as_slice(),
),
(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb").as_slice(),
),
] {
let path = dir.join(name);
if !path.exists() {
std::fs::write(path, bytes).map_err(|error| error.to_string())?;
}
}
Ok(dir)
}
/// CTB files available to the Plot dialog.
pub fn available_ctb_names() -> Vec<String> {
#[cfg(not(target_arch = "wasm32"))]
{
let Ok(dir) = ensure_plot_styles_dir() else {
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
return names;
};
let Ok(entries) = std::fs::read_dir(dir) else {
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
return names;
};
let mut names: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
.filter_map(|entry| {
let path = entry.path();
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
.then(|| entry.file_name().to_string_lossy().into_owned())
})
.collect();
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
names.sort_by_key(|name| name.to_ascii_lowercase());
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
names
}
#[cfg(target_arch = "wasm32")]
{
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
names
}
}
// ── 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,
}
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<f32>,
/// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid).
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
/// For STB: named style entries.
pub named_entries: HashMap<String, PlotStyleEntry>,
/// 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<String>,
}
impl PlotStyleTable {
/// Create an identity CTB table (no overrides for any color).
pub fn identity(name: impl Into<String>) -> 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(),
}
}
/// Load a CTB or STB file from disk.
pub fn load(path: &Path) -> Result<Self, String> {
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();
Self::from_bytes(name, &raw)
}
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
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<Self, String> {
match name.to_ascii_lowercase().as_str() {
DEFAULT_PLOT_STYLE => Self::from_bytes(
DEFAULT_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/ocad.ctb"),
),
MONOCHROME_PLOT_STYLE => Self::from_bytes(
MONOCHROME_PLOT_STYLE,
include_bytes!("../../assets/plotstyles/monochrome.ctb"),
),
"grayscale.ctb" => {
let mut table = Self::identity(GRAYSCALE_PLOT_STYLE);
for aci in 1..=255u8 {
if let Some((r, g, b)) = codec::types::aci_to_rgb(aci) {
let gray = if aci == 7 {
0
} else {
(0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32)
.round() as u8
};
table.aci_entries[aci as usize].color = Some([gray, gray, gray]);
}
}
Ok(table)
}
"fill patterns.ctb" => {
let mut table = Self::identity(FILL_PATTERNS_PLOT_STYLE);
for aci in 1..=9usize {
table.aci_entries[aci].fill_style = 63 + aci as u8;
}
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)),
_ => 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<Self, String> {
let path = Path::new(name);
if path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
{
return Err(format!("Invalid plot style name: {name}"));
}
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("stb")) {
return Err(crate::tf!(
"Named plot style tables (.stb) are not supported yet: {name}"
)
.into_owned());
}
if !path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ctb")) {
return Err(format!("Invalid plot style name: {name}"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let dir = ensure_plot_styles_dir()?;
let matched = std::fs::read_dir(&dir)
.map_err(|error| error.to_string())?
.filter_map(Result::ok)
.find(|entry| {
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(name)
})
.map(|entry| entry.path());
return match matched {
Some(path) => Self::load(&path),
None => Self::builtin(name).map_err(|_| {
// Not a built-in either: say where a copy would be found.
let folder = dir.display();
crate::tf!("{name} was not found in the plot styles folder ({folder}).")
.into_owned()
}),
};
}
#[cfg(target_arch = "wasm32")]
Self::builtin(name)
}
/// 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<f32> {
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
}
}
// ── 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>), 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<Vec<u8>, 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<String>) -> Result<Vec<u8>, 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<u8>) -> (String, Option<String>) {
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<Vec<u8>, 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<PlotStyleTable, String> {
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<i32>,
mode_color: Option<i32>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Section {
Root,
Other,
PlotStyles,
Lineweights,
}
let mut aci_entries: Vec<PlotStyleEntry> =
(0..=255).map(|_| PlotStyleEntry::default()).collect();
let mut named_entries: HashMap<String, PlotStyleEntry> = 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::<f32>::new();
let mut section = Section::Root;
let mut current: Option<PendingStyle> = 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::<usize>() {
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::<u8>() {
style.entry.screening = v.min(100);
}
}
"lineweight" => {
if let Ok(v) = value.parse::<u8>() {
style.entry.lineweight = v;
}
}
"color" => style.color = value.parse::<i32>().ok(),
"mode_color" => style.mode_color = value.parse::<i32>().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::<usize>(), value.parse::<f32>()) {
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<PlotStyleTable, String> {
let mut table = PlotStyleTable::identity(name);
table.is_stb = is_stb;
let mut style_index = 1usize;
let mut current: Option<PlotStyleEntry> = 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::<u8>().unwrap_or(100).min(100),
"lineweight" => entry.lineweight = value.parse().unwrap_or(0),
"color1" if value.starts_with('#') && value.len() == 7 => {
entry.color = Some([
u8::from_str_radix(&value[1..3], 16).unwrap_or(0),
u8::from_str_radix(&value[3..5], 16).unwrap_or(0),
u8::from_str_radix(&value[5..7], 16).unwrap_or(0),
]);
}
"color1" => entry.color = value.parse::<i32>().ok().and_then(unpack_plot_color),
_ => {}
}
}
Ok(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")
}
/// 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<u8> {
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}");
}
#[test]
fn named_tables_and_missing_files_get_a_reason() {
let error = PlotStyleTable::load_named("styles.stb").unwrap_err();
assert!(error.contains(".stb"), "{error}");
let error = PlotStyleTable::load_named("nowhere-to-be-found.ctb").unwrap_err();
assert!(error.contains("nowhere-to-be-found.ctb"), "{error}");
assert!(PlotStyleTable::load_named("../escape.ctb").is_err());
}
}
#[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<u8> {
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());
}
}