Merge pull request #1360 from HakanSeven12/plot/b4-ctb-leniency

fix(plot-style): load plot style tables written by other applications

# Conflicts:
#	src/app/update/file.rs
#	src/ui/window/plot.rs
This commit is contained in:
Hakan Seven committed 2026-09-19 23:00:19 +03:00
commit f20c823ffe
28 files changed
+339 -35

No files matched your search

+3 -1
View File
@@ -3474,7 +3474,9 @@ pub enum Message {
/// Open file dialog to load a CTB/STB plot style table.
PlotStyleLoad,
/// Callback when the user picks (or cancels) a CTB/STB file.
PlotStyleLoaded(Option<crate::io::plot_style::PlotStyleTable>),
/// The Load… picker finished: a table, nothing (cancelled), or why the
/// file could not be read.
PlotStyleLoaded(Result<Option<crate::io::plot_style::PlotStyleTable>, String>),
/// Clear the active plot style table.
PlotStyleClear,
/// Open/close the Plot Style panel.
+23 -4
View File
@@ -4670,18 +4670,22 @@ impl OpenCADStudio {
self.plot_dialog.apply_plot_styles = false;
self.plot_dialog.show_plot_styles = false;
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
} else {
match crate::io::plot_style::PlotStyleTable::load_named(&name) {
Ok(table) => {
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.apply_plot_styles = true;
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
self.report_plot_style_warnings(&table);
self.active_plot_style = Some(table);
}
Err(error) => {
self.plot_dialog.style_name = name;
self.plot_dialog.style_missing = true;
self.command_line.push_error(&error);
self.plot_dialog.style_error = Some(error);
}
}
}
@@ -4950,16 +4954,19 @@ impl OpenCADStudio {
));
self.plot_dialog.window = self.plot_window;
}
let mut style_error = None;
if !ps.current_style_sheet.is_empty()
&& self
.active_plot_style
.as_ref()
.is_none_or(|table| !table.name.eq_ignore_ascii_case(&ps.current_style_sheet))
{
if let Ok(table) =
crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet)
{
self.active_plot_style = Some(table);
match crate::io::plot_style::PlotStyleTable::load_named(&ps.current_style_sheet) {
Ok(table) => {
self.report_plot_style_warnings(&table);
self.active_plot_style = Some(table);
}
Err(error) => style_error = Some(error),
}
}
let active_style_name = self
@@ -5113,6 +5120,18 @@ impl OpenCADStudio {
d.show_plot_styles = ps.flags.show_plot_styles;
d.style_missing = !d.style_name.is_empty() && !style_loaded;
self.refresh_custom_scale_fields();
d.style_error = if d.style_missing { style_error } else { None };
}
/// Tell the user what a plot style table's loader had to tolerate.
pub(in crate::app) fn report_plot_style_warnings(
&mut self,
table: &crate::io::plot_style::PlotStyleTable,
) {
for warning in &table.load_warnings {
let name = &table.name;
self.command_line.push_warning(&format!("{name}: {warning}"));
}
}
/// Copy transient paper/scale choices out of the dialog without changing
+15 -2
View File
@@ -8946,7 +8946,7 @@ impl OpenCADStudio {
Message::PlotStyleLoad => {
Task::perform(crate::io::pick_plot_style(), Message::PlotStyleLoaded)
}
Message::PlotStyleLoaded(Some(table)) => {
Message::PlotStyleLoaded(Ok(Some(table))) => {
if table.is_stb {
self.command_line.push_error(
crate::t!(
@@ -8958,6 +8958,8 @@ impl OpenCADStudio {
}
self.plot_dialog.style_name = table.name.clone();
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
self.report_plot_style_warnings(&table);
self.command_line.push_output(
crate::tf!(
"Plot style '{}' loaded ({} color entries).",
@@ -8974,7 +8976,15 @@ impl OpenCADStudio {
self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names();
Task::none()
}
Message::PlotStyleLoaded(None) => Task::none(),
Message::PlotStyleLoaded(Ok(None)) => Task::none(),
Message::PlotStyleLoaded(Err(error)) => {
// The file the user pointed at could not be read: say why,
// in the dialog as well as on the command line.
self.command_line.push_error(&error);
self.plot_dialog.style_missing = true;
self.plot_dialog.style_error = Some(error);
Task::none()
}
Message::PlotStyleClear => {
self.active_plot_style = None;
self.plot_dialog.style_name.clear();
@@ -9001,12 +9011,15 @@ impl OpenCADStudio {
if needs_load {
match crate::io::plot_style::PlotStyleTable::load_named(&selected_style) {
Ok(table) => {
self.report_plot_style_warnings(&table);
self.active_plot_style = Some(table);
self.plot_dialog.style_missing = false;
self.plot_dialog.style_error = None;
}
Err(error) => {
self.plot_dialog.style_missing = true;
self.command_line.push_error(&error);
self.plot_dialog.style_error = Some(error);
return Task::none();
}
}
+26 -3
View File
@@ -1536,7 +1536,12 @@ mod save_failure_tests {
// ── Plot Style Table ──────────────────────────────────────────────────────
/// Show a file-open dialog and load the selected CTB or STB file.
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
/// Pick a plot style table file and load it. `Ok(None)` when the picker was
/// cancelled; `Err` says why the chosen file could not be read. A table
/// picked from outside the plot styles folder is copied into it (unless a
/// file of that name is already there), so the dialog lists it from now on
/// and page setups naming it resolve after a restart.
pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, String> {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Load Plot Style Table").as_ref())
.add_filter(crate::t!("Plot Style Tables").as_ref(), &["ctb", "CTB"])
@@ -1547,8 +1552,26 @@ pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
Ok(dir) => dialog.set_directory(dir),
Err(_) => dialog,
};
let handle = dialog.pick_file().await?;
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
let Some(handle) = dialog.pick_file().await else {
return Ok(None);
};
let path = crate::sys::handle_path(&handle);
let table = plot_style::PlotStyleTable::load(&path)?;
#[cfg(not(target_arch = "wasm32"))]
if let (Ok(dir), Some(file_name)) = (plot_style::ensure_plot_styles_dir(), path.file_name()) {
let destination = dir.join(file_name);
let same_place = path.parent().is_some_and(|parent| {
parent
.canonicalize()
.ok()
.zip(dir.canonicalize().ok())
.is_some_and(|(a, b)| a == b)
});
if !same_place && !destination.exists() {
let _ = std::fs::copy(&path, &destination);
}
}
Ok(Some(table))
}
// ── Image file picker ─────────────────────────────────────────────────────
+212 -25
View File
@@ -175,6 +175,10 @@ pub struct PlotStyleTable {
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 {
@@ -190,6 +194,7 @@ impl PlotStyleTable {
lineweights: LW_TABLE.to_vec(),
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
named_entries: HashMap::default(),
load_warnings: Vec::new(),
}
}
@@ -207,8 +212,10 @@ impl PlotStyleTable {
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 = decompress_ctb(raw)?;
parse_plot_style_text(&text, name, is_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> {
@@ -264,12 +271,18 @@ impl PlotStyleTable {
let path = Path::new(name);
if path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|| !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
{
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"))]
{
@@ -286,7 +299,12 @@ impl PlotStyleTable {
.map(|entry| entry.path());
return match matched {
Some(path) => Self::load(&path),
None => Self::builtin(name),
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()
}),
};
}
@@ -424,10 +442,18 @@ impl PlotStyleTable {
// ── Deflate helpers ───────────────────────────────────────────────────────────
/// Decompress a CTB/STB file's raw bytes into the text content.
/// Decompress a CTB/STB file's raw bytes into the text content, with the
/// things that had to be tolerated on the way.
///
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
/// 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 = Vec::new();
if data.starts_with(PREFIX) {
if data.len() < 60 {
@@ -436,20 +462,34 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
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;
if compressed_len > data.len() - 60 {
return Err("CTB compressed payload is truncated".into());
}
let payload = &data[60..60 + compressed_len];
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 {
return Err("CTB compressed payload checksum mismatch".into());
warnings.push("the compressed payload's checksum does not match".into());
}
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("CTB zlib decompress: {e}"))?;
if decoded.len() != text_len {
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 content length mismatch: expected {text_len}, got {}",
"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()
));
}
@@ -461,10 +501,7 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
.unwrap_or(0);
let payload = &data[split_at..];
if payload.starts_with(&[0x78]) {
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB zlib decompress: {e}"))?;
decoded = inflate_lenient(payload, &mut warnings)?;
} else {
use flate2::read::DeflateDecoder;
DeflateDecoder::new(payload)
@@ -472,10 +509,72 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
}
}
if decoded.last() == Some(&0) {
while decoded.last() == Some(&0) {
decoded.pop();
}
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
let (text, code_page) = decode_plot_style_text(decoded);
if let Some(note) = code_page {
warnings.push(note);
}
Ok((text, warnings))
}
/// 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};
let mut decoded = Vec::new();
match ZlibDecoder::new(payload).read_to_end(&mut decoded) {
Ok(_) => Ok(decoded),
Err(zlib_error) if payload.len() > 2 => {
let mut raw = Vec::new();
DeflateDecoder::new(&payload[2..])
.read_to_end(&mut raw)
.map_err(|_| format!("CTB zlib decompress: {zlib_error}"))?;
warnings.push("the zlib stream is damaged; its deflate data was read directly".into());
Ok(raw)
}
Err(zlib_error) => Err(format!("CTB zlib decompress: {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.
@@ -686,6 +785,7 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
lineweights,
aci_entries,
named_entries,
load_warnings: Vec::new(),
})
}
@@ -797,3 +897,90 @@ mod round_trip_tests {
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());
}
}
+2
View File
@@ -2059,6 +2059,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Plot table type" => Some(("properties", "plot-table-type")),
"Plot transparency" => Some(("plot", "plot-transparency")),
"Plot upside-down" => Some(("plot", "plot-upside-down")),
"Named plot style tables (.stb) are not supported yet: {name}" => Some(("plot", "named-plot-style-tables-stb-are-not-supported-yet-name")),
"{name} was not found in the plot styles folder ({folder})." => Some(("plot", "name-was-not-found-in-the-plot-styles-folder-folder")),
"Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}" => Some(("common", "plot-window-x0-2-y0-2-to-x1-2-y1-2")),
"Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}" => Some(("common", "plot-window-x1-2-y1-2-to-x2-2-y2-2")),
"Plugin '{id}' failed to load: {e}" => Some(("common", "plugin-id-failed-to-load-e")),
+16
View File
@@ -525,6 +525,9 @@ pub struct PlotDialogState {
/// The selected setup references a style table that is not loaded.
#[serde(skip)]
pub style_missing: bool,
/// Why the selected table could not be loaded, shown under the Table row.
#[serde(skip)]
pub style_error: Option<String>,
/// Named page setups in the document (refreshed when the dialog opens).
#[serde(skip)]
pub page_setups: Vec<String>,
@@ -596,6 +599,7 @@ impl Default for PlotDialogState {
show_plot_styles: false,
plot_styles: Vec::new(),
style_missing: false,
style_error: None,
page_setups: Vec::new(),
selected_setup: String::new(),
name_input: None,
@@ -852,6 +856,17 @@ fn custom_scale_row<'a>(s: &'a PlotDialogState, enabled: bool) -> Element<'a, Me
.into()
}
/// Why the selected plot style table is not loaded, under the Table row —
/// the file's own problem, or where a copy would be found.
fn style_note<'a>(s: &'a PlotDialogState) -> Element<'a, Message> {
match &s.style_error {
Some(error) if s.style_missing => {
text(error.clone()).size(10).style(muted_style).into()
}
_ => Space::new().height(0).into(),
}
}
fn panel<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.width(Length::Fill)
@@ -1460,6 +1475,7 @@ pub fn view_window(
PlotDlgMsg::Style,
width,
),
style_note(s),
check_enabled(
t!("Plot with plot styles"),
s.apply_plot_styles,