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

Tables copied from other applications into the plot styles folder were
listed but refused on selection, showing as "missing" with the reason
lost on a console: the loader failed hard on a checksum or a text length
that did not add up and on any non-UTF-8 byte — and those files carry
their descriptions in Windows-1252.

- decompress_ctb reads the file as it is: a checksum or length mismatch is
  reported and read past (a genuinely cut-off file is still an error), a
  zlib stream with a bad trailer is retried as raw deflate, and text that
  is not UTF-8 is read as Windows-1252 (pens and colours are numbers and
  unaffected; other code pages only show wrong characters in descriptions).
- PlotStyleTable carries load_warnings; every load site reports them on the
  command line, so a table that needed tolerance says so.
- load_named explains itself: a .stb name says named tables are not
  supported yet, a .ctb missing from the folder says which folder to copy
  it into (instead of "Unknown built-in plot style").
- The Plot dialog shows the reason under the Table row (style_error) —
  from a selection, from a page setup that names a table, and from the
  Load… picker, whose error used to be swallowed.
- A table picked with Load… from outside the plot styles folder is copied
  into it (unless a file of that name is already there), so it is listed
  from then on and page setups naming it resolve after a restart.

Tests: flipped checksum, text length off by one, a short payload field,
Windows-1252 descriptions, a truncated stream, .stb and missing names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-18 19:00:28 +03:00
1 parent 2b38f798c0
commit 7a75d430cd
28 files changed
+339 -35

No files matched your search

+2
View File
@@ -5100,6 +5100,8 @@ plot =
.plot-style-table-pen-assignments = جدول نمط الطباعة (تعيينات الأقلام)
.plot-transparency = شفافية الطباعة
.plot-upside-down = الطباعة مقلوبة
.named-plot-style-tables-stb-are-not-supported-yet-name = جداول أنماط الطباعة المسماة (‎.stb) غير مدعومة بعد: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = لم يتم العثور على __ocs_fmt_0__ في مجلد أنماط الطباعة (__ocs_fmt_1__).
.preview = معاينة
.printer-plotter = الطابعة / الراسم
.properties = الخصائص…
+2
View File
@@ -5088,6 +5088,8 @@ plot =
.plot-style-table-pen-assignments = Таблица със стилове за плотиране (присвоявания на пера)
.plot-transparency = Прозрачност при плотиране
.plot-upside-down = Плотирай обърнато наопаки
.named-plot-style-tables-stb-are-not-supported-yet-name = Именуваните таблици със стилове на печат (.stb) все още не се поддържат: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ не беше намерен в папката със стилове на печат (__ocs_fmt_1__).
.preview = Преглед
.printer-plotter = Принтер / плотер
.properties = Свойства…
+2
View File
@@ -5085,6 +5085,8 @@ plot =
.plot-style-table-pen-assignments = Tabulka stylu vykreslování (přiřazení pera)
.plot-transparency =Průhlednost plotru
.plot-upside-down = Zápletka vzhůru nohama
.named-plot-style-tables-stb-are-not-supported-yet-name = Pojmenované tabulky stylů vykreslování (.stb) zatím nejsou podporovány: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ nebyl nalezen ve složce stylů vykreslování (__ocs_fmt_1__).
.preview = Náhled
.printer-plotter = Tiskárna / plotr
.properties = Vlastnosti…
+2
View File
@@ -5086,6 +5086,8 @@ plot =
.plot-style-table-pen-assignments = Plot-Tabelle (Stiftzuweisungen)
.plot-transparency = Transparenz der Parzellen
.plot-upside-down = Plot auf dem Kopf
.named-plot-style-tables-stb-are-not-supported-yet-name = Benannte Plotstiltabellen (.stb) werden noch nicht unterstützt: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ wurde im Plotstil-Ordner (__ocs_fmt_1__) nicht gefunden.
.preview = Vorschau
.printer-plotter = Drucker/Plotter
.properties = Eigenschaften...
+2
View File
@@ -5088,6 +5088,8 @@ plot =
.plot-style-table-pen-assignments = Πίνακας στυλ εκτύπωσης (αντιστοιχίσεις πενών)
.plot-transparency = Εκτύπωση διαφάνειας
.plot-upside-down = Εκτύπωση ανάποδα
.named-plot-style-tables-stb-are-not-supported-yet-name = Οι επώνυμοι πίνακες στυλ εκτύπωσης (.stb) δεν υποστηρίζονται ακόμα: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = Το __ocs_fmt_0__ δεν βρέθηκε στον φάκελο των στυλ εκτύπωσης (__ocs_fmt_1__).
.preview = Προεπισκόπηση
.printer-plotter = Εκτυπωτής / σχεδιογράφος
.properties = Ιδιότητες…
+2
View File
@@ -5089,6 +5089,8 @@ plot =
.plot-style-table-pen-assignments = Plot style table (pen assignments)
.plot-transparency = Plot transparency
.plot-upside-down = Plot upside-down
.named-plot-style-tables-stb-are-not-supported-yet-name = Named plot style tables (.stb) are not supported yet: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ was not found in the plot styles folder (__ocs_fmt_1__).
.preview = Preview
.printer-plotter = Printer / plotter
.properties = Properties…
+2
View File
@@ -5088,6 +5088,8 @@ plot =
.plot-style-table-pen-assignments = Tabla de estilos de trazado (asignaciones de plumillas)
.plot-transparency = Transparencia de trazado
.plot-upside-down = Trazar cabeza abajo
.named-plot-style-tables-stb-are-not-supported-yet-name = Las tablas de estilos de trazado con nombre (.stb) aún no se admiten: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = No se encontró __ocs_fmt_0__ en la carpeta de estilos de trazado (__ocs_fmt_1__).
.preview = Vista preliminar
.printer-plotter = Impresora / trazador
.properties = Propiedades…
+2
View File
@@ -5087,6 +5087,8 @@ plot =
.plot-style-table-pen-assignments = Piirustyylitaulukko (kynätehtävät)
.plot-transparency =Juonen läpinäkyvyys
.plot-upside-down = Juoni ylösalaisin
.named-plot-style-tables-stb-are-not-supported-yet-name = Nimettyjä tulostustyylitaulukoita (.stb) ei vielä tueta: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = Tiedostoa __ocs_fmt_0__ ei löytynyt tulostustyylien kansiosta (__ocs_fmt_1__).
.preview = Esikatselu
.printer-plotter = Tulostin/plotteri
.properties = Ominaisuudet…
+2
View File
@@ -5088,6 +5088,8 @@ plot =
.plot-style-table-pen-assignments = Table des styles de tracé (affectation des plumes)
.plot-transparency = Transparence du lot
.plot-upside-down = Placer vers l'envers
.named-plot-style-tables-stb-are-not-supported-yet-name = Les tables de styles de tracé nommés (.stb) ne sont pas encore prises en charge : __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ est introuvable dans le dossier des styles de tracé (__ocs_fmt_1__).
.preview = Prévisualisation
.printer-plotter = Imprimante / traceur
.properties = Propriétés...
+2
View File
@@ -5076,6 +5076,8 @@ plot =
.plot-style-table-pen-assignments = प्लॉट शैली तालिका (पेन असाइनमेंट)
.plot-transparency = प्लॉट पारदर्शिता
.plot-upside-down = ऊपर की ओर नीचे प्लॉट
.named-plot-style-tables-stb-are-not-supported-yet-name = नामित प्लॉट स्टाइल तालिकाएँ (.stb) अभी समर्थित नहीं हैं: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ प्लॉट स्टाइल फ़ोल्डर (__ocs_fmt_1__) में नहीं मिला।
.preview = पूर्वावलोकन
.printer-plotter = प्रिंटर / प्लॉटर
.properties = गुण...
+2
View File
@@ -5087,6 +5087,8 @@ plot =
.plot-style-table-pen-assignments = Nyomtatási stílus táblázat (toll-hozzárendelések)
.plot-transparency =A telek átláthatósága
.plot-upside-down = Telek fejjel lefelé
.named-plot-style-tables-stb-are-not-supported-yet-name = A nevesített nyomtatási stílustáblák (.stb) még nem támogatottak: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = A(z) __ocs_fmt_0__ nem található a nyomtatási stílusok mappájában (__ocs_fmt_1__).
.preview = Előnézet
.printer-plotter = Nyomtató / plotter
.properties = Tulajdonságok…
+2
View File
@@ -5087,6 +5087,8 @@ plot =
.plot-style-table-pen-assignments = Tabella stili di stampa (assegnazioni penne)
.plot-transparency =Trasparenza della trama
.plot-upside-down = Trama sottosopra
.named-plot-style-tables-stb-are-not-supported-yet-name = Le tabelle di stili di stampa con nome (.stb) non sono ancora supportate: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ non è stato trovato nella cartella degli stili di stampa (__ocs_fmt_1__).
.preview = Anteprima
.printer-plotter = Stampante/plotter
.properties = Proprietà…
+2
View File
@@ -5076,6 +5076,8 @@ plot =
.plot-style-table-pen-assignments = 印刷スタイルテーブル(ペン割当)
.plot-transparency = 透明度を印刷
.plot-upside-down = 上下反転印刷
.named-plot-style-tables-stb-are-not-supported-yet-name = 名前付き印刷スタイルテーブル (.stb) はまだサポートされていません: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ は印刷スタイルフォルダー (__ocs_fmt_1__) に見つかりませんでした。
.preview = プレビュー
.printer-plotter = プリンター / プロッター
.properties = プロパティ…
+2
View File
@@ -5087,6 +5087,8 @@ plot =
.plot-style-table-pen-assignments = 플롯 스타일 테이블(펜 지정)
.plot-transparency =플롯 투명도
.plot-upside-down = 거꾸로 플롯
.named-plot-style-tables-stb-are-not-supported-yet-name = 명명된 플롯 스타일 테이블(.stb)은 아직 지원되지 않습니다: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__을(를) 플롯 스타일 폴더(__ocs_fmt_1__)에서 찾을 수 없습니다.
.preview = 미리보기
.printer-plotter = 프린터/플로터
.properties = 속성…
+2
View File
@@ -5086,6 +5086,8 @@ plot =
.plot-style-table-pen-assignments = Plotstijltabel (pentoewijzingen)
.plot-transparency = Doorzichtigheid van de plaats van uitvoering
.plot-upside-down = Ondersteboven plotten
.named-plot-style-tables-stb-are-not-supported-yet-name = Benoemde plotstijltabellen (.stb) worden nog niet ondersteund: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ is niet gevonden in de map met plotstijlen (__ocs_fmt_1__).
.preview = Voorbeeld
.printer-plotter = Printer/plotter
.properties = Eigenschappen...
+2
View File
@@ -5087,6 +5087,8 @@ plot =
.plot-style-table-pen-assignments = Tabela stylów wydruku (przypisania pisaków)
.plot-transparency =Przejrzystość fabuły
.plot-upside-down = Fabuła do góry nogami
.named-plot-style-tables-stb-are-not-supported-yet-name = Nazwane tabele stylów wydruku (.stb) nie są jeszcze obsługiwane: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = Nie znaleziono __ocs_fmt_0__ w folderze stylów wydruku (__ocs_fmt_1__).
.preview = Podgląd
.printer-plotter = Drukarka / ploter
.properties = Właściwości…
+2
View File
@@ -5088,6 +5088,8 @@ plot =
.plot-style-table-pen-assignments = Tabela de estilos de plotagem (atribuições de penas)
.plot-transparency = Transparência de plotagem
.plot-upside-down = Plotar de cabeça para baixo
.named-plot-style-tables-stb-are-not-supported-yet-name = Tabelas de estilos de plotagem nomeados (.stb) ainda não são suportadas: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ não foi encontrado na pasta de estilos de plotagem (__ocs_fmt_1__).
.preview = Visualização
.printer-plotter = Impressora / plotadora
.properties = Propriedades…
+2
View File
@@ -5089,6 +5089,8 @@ plot =
.plot-style-table-pen-assignments = Стол стиля сюжета (пен-задания)
.plot-transparency = Прозрачность сюжета
.plot-upside-down = Заговор вверх ногами
.named-plot-style-tables-stb-are-not-supported-yet-name = Именованные таблицы стилей печати (.stb) пока не поддерживаются: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ не найден в папке стилей печати (__ocs_fmt_1__).
.preview = Предпросмотр
.printer-plotter = Принтер / Плоттер
.properties = Свойства...
+2
View File
@@ -5028,6 +5028,8 @@ plot =
.plot-style-table-pen-assignments = Çizim stili tablosu (kalem atamaları)
.plot-transparency = Saydamlığı yazdır
.plot-upside-down = Ters yazdır
.named-plot-style-tables-stb-are-not-supported-yet-name = Adlandırılmış çizim stili tabloları (.stb) henüz desteklenmiyor: __ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__, çizim stilleri klasöründe (__ocs_fmt_1__) bulunamadı.
.preview = Önizleme
.printer-plotter = Yazıcı / çizici
.properties = Özellikler…
+2
View File
@@ -5071,6 +5071,8 @@ plot =
.plot-style-table-pen-assignments = 打印样式表(笔指定)
.plot-transparency = 打印透明度
.plot-upside-down = 上下颠倒打印
.named-plot-style-tables-stb-are-not-supported-yet-name = 尚不支持命名打印样式表 (.stb):__ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = 在打印样式文件夹 (__ocs_fmt_1__) 中找不到 __ocs_fmt_0__。
.preview = 预览
.printer-plotter = 打印机/绘图仪
.properties = 特性…
+2
View File
@@ -5083,6 +5083,8 @@ plot =
.plot-style-table-pen-assignments = 列印樣式表(筆指定)
.plot-transparency = 列印透明度
.plot-upside-down = 上下顛倒列印
.named-plot-style-tables-stb-are-not-supported-yet-name = 尚不支援具名出圖型式表 (.stb):__ocs_fmt_0__
.name-was-not-found-in-the-plot-styles-folder-folder = 在出圖型式資料夾 (__ocs_fmt_1__) 中找不到 __ocs_fmt_0__。
.preview = 預覽
.printer-plotter = 印表機/繪圖儀
.properties = 特性…
+3 -1
View File
@@ -3422,7 +3422,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
@@ -4468,18 +4468,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);
}
}
}
@@ -4744,16 +4748,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
@@ -4901,6 +4908,18 @@ impl OpenCADStudio {
d.apply_plot_styles = ps.flags.plot_plot_styles;
d.show_plot_styles = ps.flags.show_plot_styles;
d.style_missing = !d.style_name.is_empty() && !style_loaded;
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
@@ -8762,7 +8762,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!(
@@ -8774,6 +8774,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).",
@@ -8790,7 +8792,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();
@@ -8817,12 +8827,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
@@ -1522,7 +1522,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"])
@@ -1533,8 +1538,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
@@ -431,6 +431,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>,
@@ -489,6 +492,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,
@@ -670,6 +674,17 @@ fn check_enabled<'a>(
.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)
@@ -1241,6 +1256,7 @@ pub fn view_window(
PlotDlgMsg::Style,
width,
),
style_note(s),
check_enabled(
t!("Plot with plot styles"),
s.apply_plot_styles,