Merge pull request #19 from justinjohn0306/fix/vba-macro-preservation
Preserve VBA macros + Excel-style macros UX (issue #6)
This commit is contained in:
commit
fb3737aa03
|
|
@ -876,10 +876,16 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
}
|
||||
|
||||
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
|
||||
Ok(Json::Array(
|
||||
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(),
|
||||
))
|
||||
let wb = &s.doc()?.wb;
|
||||
let all = stash_get(wb, SCRIPTS_NAME);
|
||||
let mut list: Vec<Json> =
|
||||
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
|
||||
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
|
||||
// save but never executed here, so it has no step count.
|
||||
if let Some(bytes) = &wb.vba_project {
|
||||
list.push(json!({"name": "(VBA)", "steps": 0, "kind": "vba", "size": bytes.len(), "executed": false}));
|
||||
}
|
||||
Ok(Json::Array(list))
|
||||
}
|
||||
|
||||
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
|
|
|||
|
|
@ -21,17 +21,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fn dictionary() -> &'static HashSet<String> {
|
||||
static DICT: OnceLock<HashSet<String>> = OnceLock::new();
|
||||
DICT.get_or_init(|| {
|
||||
let mut set = HashSet::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
for w in text.lines().take(2_000_000) {
|
||||
set.insert(w.trim().to_lowercase());
|
||||
}
|
||||
break;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
HashSet::new()
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut set = HashSet::new();
|
||||
for path in ["/usr/share/dict/words", "/usr/share/dict/american-english", "/usr/share/dict/british-english"] {
|
||||
if let Ok(text) = std::fs::read_to_string(path) {
|
||||
for w in text.lines().take(2_000_000) {
|
||||
set.insert(w.trim().to_lowercase());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
set
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,13 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
|||
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
let sheet = wb.active_sheet;
|
||||
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
||||
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
|
||||
// keeps the macros; any other name saves without them, as Excel does.
|
||||
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
|
||||
let mut plain = wb.clone();
|
||||
plain.vba_project = None;
|
||||
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Xls => {
|
||||
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
|
|
@ -366,6 +373,20 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn macros_are_saved_only_into_xlsm() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.vba_project = Some(b"vba".to_vec());
|
||||
let has_macros = |path: &str| {
|
||||
let bytes = save_bytes(&wb, path).unwrap();
|
||||
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
|
||||
};
|
||||
assert!(has_macros("book.xlsm"));
|
||||
assert!(has_macros("BOOK.XLSM"));
|
||||
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
|
||||
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
|
||||
}
|
||||
|
||||
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
|
||||
/// root entry and the given stream names.
|
||||
fn cfb(streams: &[&str]) -> Vec<u8> {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,26 @@ pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error>
|
|||
decode(&s).ok_or_else(|| serde::de::Error::custom("invalid base64"))
|
||||
}
|
||||
|
||||
/// Base64 for optional byte blobs (VBA projects): `null`/missing stays `None`.
|
||||
pub mod opt {
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(data) => s.serialize_str(&super::encode(data)),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
|
||||
let s = Option::<String>::deserialize(d)?;
|
||||
match s {
|
||||
None => Ok(None),
|
||||
Some(s) => super::decode(&s).map(Some).ok_or_else(|| serde::de::Error::custom("invalid base64")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ pub struct Workbook {
|
|||
pub custom_lists: Vec<Vec<String>>,
|
||||
/// Named cell styles (Cell Styles gallery): name → style.
|
||||
pub cell_styles: Vec<(String, crate::style::Style)>,
|
||||
/// VBA project (`xl/vbaProject.bin`) carried through unchanged. GridCraft does not run
|
||||
/// macros; the blob is preserved so that saving a macro-enabled workbook never destroys the
|
||||
/// macros it arrived with. base64 in JSON. `None` = the workbook has no macros.
|
||||
#[serde(default, with = "crate::b64::opt")]
|
||||
pub vba_project: Option<Vec<u8>>,
|
||||
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
|
|
@ -97,6 +102,7 @@ impl Workbook {
|
|||
protected_structure: false,
|
||||
custom_lists: vec![],
|
||||
cell_styles: vec![],
|
||||
vba_project: None,
|
||||
preserved: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -694,6 +694,11 @@ impl Dialog {
|
|||
)
|
||||
}
|
||||
"journal" => Dialog::custom("journal", "Action Journal", json!({})),
|
||||
"macros" => {
|
||||
let mut d = Dialog::custom("macros", "Macros", json!({}));
|
||||
d.result = app.session.run("view.macros", json!({})).ok();
|
||||
d
|
||||
}
|
||||
"statistics" => {
|
||||
let mut d = Dialog::custom("statistics", "Workbook Statistics", json!({}));
|
||||
d.result = app.session.run("review.workbookStatistics", json!({})).ok();
|
||||
|
|
@ -846,7 +851,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
let width = match d.name.as_str() {
|
||||
"formatCells" => 560.0,
|
||||
"about" => 660.0,
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
|
||||
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
|
||||
"recommendedCharts" => 600.0,
|
||||
"analysisToolPak" => 420.0,
|
||||
"solver" => 460.0,
|
||||
|
|
@ -893,6 +898,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
}
|
||||
"macros" => macros(ui, &mut d),
|
||||
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
|
||||
let r = if d.name == "comments" {
|
||||
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::<Vec<_>>())))
|
||||
|
|
@ -1285,6 +1291,34 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
|
||||
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
|
||||
/// preserved and not executed.
|
||||
fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
|
||||
ui.add_space(6.0);
|
||||
let items: Vec<Json> = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
ui.weak("This workbook has no scripts or macros.");
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for it in &items {
|
||||
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
|
||||
let is_vba = it.get("kind").and_then(Json::as_str) == Some("vba");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(name).strong());
|
||||
if is_vba {
|
||||
ui.weak("— VBA project (preserved, not run)");
|
||||
} else {
|
||||
let steps = it.get("steps").and_then(Json::as_u64).unwrap_or(0);
|
||||
ui.weak(format!("— {steps} step{}", if steps == 1 { "" } else { "s" }));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
ui.horizontal(|ui| {
|
||||
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {
|
||||
|
|
|
|||
|
|
@ -2272,6 +2272,9 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
Key::Space if !cmd && !shift && is_checkbox(sh, app) => app.run_or_alert("cell.toggleCheckbox", json!({})),
|
||||
Key::Space if cmd && !shift => app.run_or_alert("selection.column", json!({})),
|
||||
Key::Space if shift && !cmd => app.run_or_alert("selection.row", json!({})),
|
||||
Key::F8 if m.alt => {
|
||||
app.open_dialog("macros", json!({}));
|
||||
}
|
||||
Key::F9 => app.run_or_alert("formulas.calculateNow", json!({})),
|
||||
Key::F4 if !cmd => {}
|
||||
_ if cmd => crate::ribbon::shortcut(app, key, m),
|
||||
|
|
@ -2335,7 +2338,12 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
|||
let Some(sh) = wb.sheet(si) else { return };
|
||||
let _ = geo;
|
||||
// Tables get an autofilter on demand.
|
||||
let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range);
|
||||
let table_range = sh
|
||||
.tables
|
||||
.iter()
|
||||
.find(|t| t.range.start.row <= at.y as u32 || true)
|
||||
.filter(|t| col >= t.range.start.col && col <= t.range.end.col)
|
||||
.map(|t| t.range);
|
||||
if sh.autofilter.is_none()
|
||||
&& let Some(r) = table_range
|
||||
{
|
||||
|
|
|
|||
|
|
@ -132,6 +132,23 @@ pub struct SheetView {
|
|||
pub scroll: egui::Vec2,
|
||||
}
|
||||
|
||||
/// Severity of a notice shown in the info bar under the ribbon.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NoticeKind {
|
||||
Info,
|
||||
Warning,
|
||||
}
|
||||
|
||||
/// A dismissible bar under the ribbon, in the spirit of Excel's message bar. Used to tell the
|
||||
/// user what an opened file carried that GridCraft handled differently (e.g. preserved macros).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Notice {
|
||||
pub text: String,
|
||||
pub kind: NoticeKind,
|
||||
/// Optional button: `(label, dialog name)` — opens that dialog when clicked.
|
||||
pub action: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Host services the platform shell provides (file pickers, opening URLs).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
|
|
@ -183,6 +200,8 @@ pub struct SheetApp {
|
|||
pub backstage: Option<backstage::Backstage>,
|
||||
pub message: Option<(String, String)>,
|
||||
pub toast: Option<(String, f64)>,
|
||||
/// Dismissible info bar under the ribbon (open-time notices).
|
||||
pub notice: Option<Notice>,
|
||||
pub services: Services,
|
||||
/// Synthetic input from agents, one batch per frame (drags need several frames).
|
||||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||||
|
|
@ -220,6 +239,7 @@ impl SheetApp {
|
|||
backstage: None,
|
||||
message: None,
|
||||
toast: None,
|
||||
notice: None,
|
||||
services,
|
||||
synthetic: Default::default(),
|
||||
control_rx: None,
|
||||
|
|
@ -474,6 +494,18 @@ impl SheetApp {
|
|||
self.message = None;
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.notice" => Ok(match &self.notice {
|
||||
Some(n) => json!({
|
||||
"text": n.text,
|
||||
"kind": match n.kind { NoticeKind::Warning => "warning", NoticeKind::Info => "info" },
|
||||
"action": n.action,
|
||||
}),
|
||||
None => Json::Null,
|
||||
}),
|
||||
"ui.notice.dismiss" => {
|
||||
self.notice = None;
|
||||
Ok(Json::Null)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -557,16 +589,48 @@ impl SheetApp {
|
|||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
match self.run("file.open", json!({"path": path})) {
|
||||
Ok(_) => {
|
||||
Ok(r) => {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(20);
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
|
||||
/// Turns the `warnings` a `file.open` result carries into an info-bar notice, choosing the
|
||||
/// most important one (a preserved VBA project outranks, say, a skipped optional part).
|
||||
/// Kept deliberately generic: any open-time warning can reach the user this way.
|
||||
pub fn show_open_warnings(&mut self, r: &Json) {
|
||||
let Some(list) = r.get("warnings").and_then(Json::as_array) else {
|
||||
self.notice = None;
|
||||
return;
|
||||
};
|
||||
let msgs: Vec<&str> = list.iter().filter_map(Json::as_str).collect();
|
||||
if msgs.is_empty() {
|
||||
// A clean open clears any notice left over from the previous document.
|
||||
self.notice = None;
|
||||
return;
|
||||
}
|
||||
let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect();
|
||||
if !vba.is_empty() {
|
||||
self.notice = Some(Notice {
|
||||
text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(),
|
||||
kind: NoticeKind::Warning,
|
||||
action: Some(("What this means".into(), "macros".into())),
|
||||
});
|
||||
} else {
|
||||
let text = if msgs.len() == 1 {
|
||||
msgs[0].to_string()
|
||||
} else {
|
||||
format!("{} items in this file were handled differently. First: {}", msgs.len(), msgs[0])
|
||||
};
|
||||
self.notice = Some(Notice { text, kind: NoticeKind::Info, action: None });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view_key(&self) -> Option<(u64, usize)> {
|
||||
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
|
||||
}
|
||||
|
|
@ -742,7 +806,10 @@ impl SheetApp {
|
|||
let b64 = gridcraft_engine::io::base64_encode(&bytes);
|
||||
match self.run("file.open", json!({"name": name, "base64": b64})) {
|
||||
// A file picked from File › Open (web) arrives here: show it, not the Backstage.
|
||||
Ok(_) => self.backstage = None,
|
||||
Ok(r) => {
|
||||
self.backstage = None;
|
||||
self.show_open_warnings(&r);
|
||||
}
|
||||
Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))),
|
||||
}
|
||||
self.after_engine();
|
||||
|
|
@ -779,6 +846,7 @@ impl SheetApp {
|
|||
backstage::show(self, ui);
|
||||
} else {
|
||||
ribbon::show(self, ui);
|
||||
widgets::notice_bar(self, ui);
|
||||
if self.session.prefs.formula_bar {
|
||||
formula_bar::show(self, ui);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1844,6 +1844,9 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
|
||||
fn automate(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
|
||||
app.open_dialog("macros", json!({}));
|
||||
}
|
||||
if big_button(ui, Icon::Script, "Command\nPalette", "Search and run any command", false).clicked() {
|
||||
app.open_dialog("commandSearch", json!({}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,6 +229,10 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
|||
};
|
||||
ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
if let Some(d) = app.session.active() {
|
||||
if d.wb.vba_project.is_some() {
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new("Macros preserved (not executed)").font(theme::ui_font(12.5)).color(t.danger));
|
||||
}
|
||||
if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual {
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ pub struct Tokens {
|
|||
pub menu_bg: Color32,
|
||||
pub shadow: Color32,
|
||||
pub danger: Color32,
|
||||
/// Info-bar (notice strip) fills, by severity.
|
||||
pub notice_warn_bg: Color32,
|
||||
pub notice_warn_border: Color32,
|
||||
pub notice_info_bg: Color32,
|
||||
pub notice_info_border: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
|
@ -83,6 +88,10 @@ impl Tokens {
|
|||
menu_bg: Color32::WHITE,
|
||||
shadow: Color32::from_black_alpha(40),
|
||||
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
|
||||
notice_warn_bg: Color32::from_rgb(0xFF, 0xF4, 0xCE),
|
||||
notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E),
|
||||
notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB),
|
||||
notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +130,10 @@ impl Tokens {
|
|||
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
|
||||
shadow: Color32::from_black_alpha(90),
|
||||
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
|
||||
notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
|
||||
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
|
||||
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
|
||||
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -236,3 +236,51 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
|
||||
/// The info bar under the ribbon (Excel's message-bar slot). Shows an open-time notice with an
|
||||
/// optional action button and a dismiss ✕. Returns nothing; dismissing clears `app.notice`.
|
||||
pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let Some(notice) = app.notice.clone() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (bg, border) = match notice.kind {
|
||||
crate::NoticeKind::Warning => (t.notice_warn_bg, t.notice_warn_border),
|
||||
crate::NoticeKind::Info => (t.notice_info_bg, t.notice_info_border),
|
||||
};
|
||||
let mut dismiss = false;
|
||||
let mut action: Option<(String, String)> = None;
|
||||
egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| {
|
||||
// Bottom border as a 1px strip spanning the bar.
|
||||
let bar = ui.max_rect();
|
||||
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside);
|
||||
ui.horizontal_centered(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
r,
|
||||
match notice.kind {
|
||||
crate::NoticeKind::Warning => Icon::Lock,
|
||||
crate::NoticeKind::Info => Icon::Note,
|
||||
},
|
||||
border,
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(¬ice.text).font(theme::ui_font(12.5)).color(t.text));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icon_button(ui, Icon::Close, t.text_dim, "Dismiss", vec2(20.0, 20.0)).clicked() {
|
||||
dismiss = true;
|
||||
}
|
||||
if let Some((label, dialog)) = ¬ice.action
|
||||
&& ui.button(egui::RichText::new(label).font(theme::ui_font(12.5))).clicked()
|
||||
{
|
||||
action = Some((label.clone(), dialog.clone()));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some((_, dialog)) = action {
|
||||
app.open_dialog(&dialog, serde_json::json!({}));
|
||||
}
|
||||
if dismiss {
|
||||
app.notice = None;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@ fn dialogs_open_and_close() {
|
|||
"goTo",
|
||||
"pivotCalculatedField",
|
||||
"about",
|
||||
"macros",
|
||||
"analysisToolPak",
|
||||
"solver",
|
||||
] {
|
||||
|
|
@ -484,6 +485,90 @@ fn column_autocomplete_completes_on_enter() {
|
|||
assert_eq!(value(&h, "A5"), Value::from("Ea"));
|
||||
}
|
||||
|
||||
/// An .xlsm carrying a VBA project, saved to bytes.
|
||||
fn macro_workbook_bytes() -> Vec<u8> {
|
||||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||||
wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec());
|
||||
gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_macro_workbook_shows_a_notice() {
|
||||
// The reader's "macros kept but not executed" warning must reach a dismissible info bar,
|
||||
// not be silently dropped (issue #6 follow-up: Excel-style message bar).
|
||||
let mut h = harness(blank());
|
||||
let b64 = gridcraft_engine::io::base64_encode(¯o_workbook_bytes());
|
||||
let res = h.state_mut().session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap();
|
||||
assert!(
|
||||
res.get("warnings").and_then(|w| w.as_array()).is_some_and(|a| a.iter().any(|m| m.as_str().is_some_and(|s| s.contains("macros")))),
|
||||
"engine reports the macro warning: {res}"
|
||||
);
|
||||
// The UI-side handler turns that open result into the notice (open_path does this in the app).
|
||||
h.state_mut().show_open_warnings(&res);
|
||||
let n = h.state().notice.clone().expect("a notice was shown");
|
||||
assert!(n.text.contains("VBA"), "{}", n.text);
|
||||
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
|
||||
assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"), "action opens the Macros dialog");
|
||||
|
||||
// The notice bar renders without panic; `ui.notice` surfaces it; dismissing clears it.
|
||||
h.run_steps(2);
|
||||
let got = h.state_mut().run("ui.notice", json!({})).unwrap();
|
||||
assert_eq!(got.get("kind").and_then(|k| k.as_str()), Some("warning"));
|
||||
h.state_mut().run("ui.notice.dismiss", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().notice.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_open_warning_becomes_an_info_notice() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().show_open_warnings(&json!({"warnings": ["links to external workbooks are not supported"]}));
|
||||
let n = h.state().notice.clone().unwrap();
|
||||
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Info);
|
||||
assert!(n.action.is_none());
|
||||
assert!(n.text.contains("external"));
|
||||
// No warnings → no notice, no panic.
|
||||
h.state_mut().show_open_warnings(&json!({"warnings": []}));
|
||||
assert!(h.state().notice.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_dialog_lists_vba_and_scripts() {
|
||||
let mut s = blank();
|
||||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||||
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
|
||||
let mut h = harness(s);
|
||||
h.state_mut().open_dialog("macros", json!({}));
|
||||
h.run_steps(3);
|
||||
let d = h.state().dialog.as_ref().expect("macros dialog open");
|
||||
assert_eq!(d.name(), "macros");
|
||||
let items = d.result.as_ref().and_then(|r| r.as_array()).cloned().unwrap_or_default();
|
||||
assert!(items.iter().any(|i| i.get("kind").and_then(|k| k.as_str()) == Some("vba")), "{items:?}");
|
||||
h.state_mut().dialog = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_shows_macro_badge() {
|
||||
let mut s = blank();
|
||||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||||
wb.vba_project = Some(vec![0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
s.active_mut().unwrap().wb = std::sync::Arc::new(wb);
|
||||
// Rendering the status bar must not panic with a VBA project present.
|
||||
let mut h = harness(s);
|
||||
h.run_steps(3);
|
||||
h.state_mut().ui.dark = true;
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_f8_opens_the_macros_dialog() {
|
||||
let mut h = harness(blank());
|
||||
key(&mut h, Key::F8, Modifiers::ALT);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name().to_string()).as_deref(), Some("macros"));
|
||||
h.state_mut().dialog = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_numbers_fit_by_the_cells_own_font() {
|
||||
// 9pt marks in narrow columns (a stored width of 4, i.e. 28px; common in mark sheets): "100" fits.
|
||||
|
|
|
|||
|
|
@ -313,8 +313,18 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
wb.props.company = x.child("Company").map(|e| e.text.trim().to_string()).unwrap_or_default();
|
||||
}
|
||||
|
||||
if wb_rels.iter().any(|r| r.kind == "vbaProject") {
|
||||
cx.warn("macros (VBA project) are not supported and were dropped");
|
||||
// A VBA project is carried through unchanged: GridCraft never runs macros, but preserving the
|
||||
// binary keeps macro-enabled workbooks from being silently stripped on save.
|
||||
if let Some(r) = find_rel(&wb_rels, "vbaProject").cloned() {
|
||||
match cx.pkg.read(&r.target) {
|
||||
Ok(Some(data)) => {
|
||||
wb.vba_project = Some(data);
|
||||
cx.warn("macros (VBA project) were kept but not executed");
|
||||
}
|
||||
Ok(None) => cx.warn("macros (VBA project) reference a missing part and were dropped"),
|
||||
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
|
||||
Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")),
|
||||
}
|
||||
}
|
||||
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml);
|
||||
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
|
||||
|
|
|
|||
|
|
@ -621,6 +621,56 @@ fn writing_repairs_invalid_models() {
|
|||
assert_eq!(s.cell(at("B2")).unwrap().style, StyleId::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vba_project_roundtrip() {
|
||||
// A VBA project blob is preserved byte-identically through write and read, and the package
|
||||
// carries the macro-enabled content type plus the vbaProject relationship.
|
||||
let vba = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec();
|
||||
let mut wb = Workbook::new();
|
||||
wb.vba_project = Some(vba.clone());
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
assert_eq!(write_xlsx(&wb).unwrap(), bytes, "output is deterministic");
|
||||
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
|
||||
let names: Vec<String> = (0..z.len()).map(|i| z.name_for_index(i).unwrap().to_string()).collect();
|
||||
assert!(names.iter().any(|x| x == "xl/vbaProject.bin"), "missing vbaProject.bin");
|
||||
let mut bin = vec![];
|
||||
z.by_name("xl/vbaProject.bin").unwrap().read_to_end(&mut bin).unwrap();
|
||||
assert_eq!(bin, vba, "vbaProject.bin written byte-identically");
|
||||
let mut ct = String::new();
|
||||
z.by_name("[Content_Types].xml").unwrap().read_to_string(&mut ct).unwrap();
|
||||
assert!(ct.contains("macroEnabled.main+xml"), "macro-enabled main content type: {ct}");
|
||||
assert!(ct.contains("/xl/vbaProject.bin"), "vbaProject content type override: {ct}");
|
||||
let mut rels = String::new();
|
||||
z.by_name("xl/_rels/workbook.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
|
||||
assert!(rels.contains("relationships/vbaProject"), "vbaProject relationship: {rels}");
|
||||
|
||||
let (back, rep) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(back.vba_project.as_deref(), Some(vba.as_slice()), "vbaProject survives the read");
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings);
|
||||
|
||||
// A workbook without macros stays macro-free and free of VBA warnings.
|
||||
let (plain, rp) = read_xlsx(&write_xlsx(&Workbook::new()).unwrap()).unwrap();
|
||||
assert_eq!(plain.vba_project, None);
|
||||
assert!(rp.warnings.iter().all(|w| !w.contains("VBA")), "{:?}", rp.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vba_project_read_from_foreign_file() {
|
||||
// An .xlsm built by another producer is captured on read (not dropped with a plain warning).
|
||||
let vba: &[u8] = b"PK\x03\x04-not-really-but-opaque-bytes";
|
||||
let bytes = super::minimal(
|
||||
"<sheetData/>",
|
||||
&[("xl/vbaProject.bin", "ignored-as-text")],
|
||||
r#"<Relationship Id="rId9" Type="http://schemas.microsoft.com/office/2006/relationships/vbaProject" Target="vbaProject.bin"/>"#,
|
||||
"",
|
||||
);
|
||||
let (wb, rep) = read_xlsx(&bytes).unwrap();
|
||||
assert!(wb.vba_project.is_some(), "VBA blob captured");
|
||||
assert!(rep.warnings.iter().any(|w| w.contains("not executed")), "{:?}", rep.warnings);
|
||||
let _ = vba;
|
||||
}
|
||||
|
||||
fn sheet1_xml(bytes: &[u8]) -> (String, bool) {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut s = String::new();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ pub const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.draw
|
|||
pub const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
|
||||
pub const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml";
|
||||
pub const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml";
|
||||
/// Main workbook part: macro-free and macro-enabled variants (Excel refuses a `.xlsm` whose main
|
||||
/// part uses the macro-free content type, and vice versa).
|
||||
pub const CT_WORKBOOK_MAIN: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml";
|
||||
pub const CT_WORKBOOK_MAIN_MACRO: &str = "application/vnd.ms-excel.sheet.macroEnabled.main+xml";
|
||||
pub const CT_VBA: &str = "application/vnd.ms-office.vbaProject";
|
||||
pub const CT_THREADED_COMMENTS: &str = "application/vnd.ms-excel.threadedcomments+xml";
|
||||
pub const CT_PERSONS: &str = "application/vnd.ms-excel.person+xml";
|
||||
pub const REL_THREADED_COMMENT: &str = "http://schemas.microsoft.com/office/2017/10/relationships/threadedComment";
|
||||
|
|
@ -39,6 +44,9 @@ impl Rels {
|
|||
let id = format!("rId{}", self.items.len() + 1);
|
||||
let ty = match kind {
|
||||
"core-properties" => "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties".to_string(),
|
||||
// The VBA project relationship lives in the Microsoft-documented namespace, not the
|
||||
// OpenXML one; Excel rejects the file if this type is wrong.
|
||||
"vbaProject" => "http://schemas.microsoft.com/office/2006/relationships/vbaProject".to_string(),
|
||||
k => format!("{NS_REL}/{k}"),
|
||||
};
|
||||
self.items.push((id.clone(), ty, target.to_string(), false));
|
||||
|
|
@ -337,7 +345,11 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
|
|||
out.part("xl/persons/person.xml", Some(CT_PERSONS), x.into_bytes());
|
||||
wb_rels.add_uri(REL_PERSON, "persons/person.xml");
|
||||
}
|
||||
out.part("xl/workbook.xml", Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"), w.into_bytes());
|
||||
out.part("xl/workbook.xml", Some(if wb.vba_project.is_some() { CT_WORKBOOK_MAIN_MACRO } else { CT_WORKBOOK_MAIN }), w.into_bytes());
|
||||
if let Some(data) = &wb.vba_project {
|
||||
wb_rels.add("vbaProject", "vbaProject.bin");
|
||||
out.part("xl/vbaProject.bin", Some(CT_VBA), data.clone());
|
||||
}
|
||||
out.part("xl/_rels/workbook.xml.rels", None, wb_rels.xml().into_bytes());
|
||||
|
||||
// Document properties.
|
||||
|
|
|
|||
Loading…
Reference in New Issue