Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 f86aec12da Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:18 -04:00
Brandon ThomasandClaude Opus 5.5 bca67944b8 macOS signing: keep the temporary keychain until the build has signed with it
import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:14:00 -04:00
Brandon ThomasandClaude Opus 5.5 4995ecc66e Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:04:14 -04:00
50 changed files with 371 additions and 409 deletions
Generated
+16 -16
View File
@@ -1505,7 +1505,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft" name = "gridcraft"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-calc" name = "gridcraft-calc"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"gridcraft-formula", "gridcraft-formula",
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-chart" name = "gridcraft-chart"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-calc", "gridcraft-calc",
"gridcraft-core", "gridcraft-core",
@@ -1551,7 +1551,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-cli" name = "gridcraft-cli"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"gridcraft-mcp", "gridcraft-mcp",
@@ -1563,7 +1563,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-core" name = "gridcraft-core"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"js-sys", "js-sys",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-engine" name = "gridcraft-engine"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-calc", "gridcraft-calc",
"gridcraft-chart", "gridcraft-chart",
@@ -1595,7 +1595,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-formula" name = "gridcraft-formula"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"serde", "serde",
@@ -1604,7 +1604,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-functions" name = "gridcraft-functions"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"regex", "regex",
@@ -1612,7 +1612,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-mcp" name = "gridcraft-mcp"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"log", "log",
@@ -1621,7 +1621,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-model" name = "gridcraft-model"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
"gridcraft-formula", "gridcraft-formula",
@@ -1633,18 +1633,18 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-numfmt" name = "gridcraft-numfmt"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-core", "gridcraft-core",
] ]
[[package]] [[package]]
name = "gridcraft-pdf" name = "gridcraft-pdf"
version = "0.11.1" version = "0.12.1"
[[package]] [[package]]
name = "gridcraft-ui-egui" name = "gridcraft-ui-egui"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"egui", "egui",
@@ -1666,7 +1666,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-web" name = "gridcraft-web"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -1683,7 +1683,7 @@ dependencies = [
[[package]] [[package]]
name = "gridcraft-xlsx" name = "gridcraft-xlsx"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"aes", "aes",
"gridcraft-core", "gridcraft-core",
@@ -4537,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.11.1" version = "0.12.1"
dependencies = [ dependencies = [
"gridcraft-engine", "gridcraft-engine",
"serde_json", "serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package] [workspace.package]
version = "0.11.1" version = "0.12.1"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.90"
+2 -2
View File
@@ -318,7 +318,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
if !walk(&p, depth + 1, out, seen) { if !walk(&p, depth + 1, out, seen) {
return false; return false;
} }
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
&& !out.add_file(p) && !out.add_file(p)
{ {
return false; return false;
@@ -333,7 +333,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport { pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts))); let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
guarded.unwrap_or_else(|p| { guarded.unwrap_or_else(|p| {
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default(); let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() } FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
}) })
} }
+1 -1
View File
@@ -352,7 +352,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok()); js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
if let Some(picker_fn) = show_picker_fn { if let Some(picker_fn) = show_picker_fn {
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase(); let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
let dot_ext = format!(".{ext}"); let dot_ext = format!(".{ext}");
let mime = mime_for(name); let mime = mime_for(name);
+2 -2
View File
@@ -180,11 +180,11 @@ fn services() -> Services {
})), })),
pick_save: Some(Box::new(|suggested: &str| { pick_save: Some(Box::new(|suggested: &str| {
let path = std::path::Path::new(suggested); let path = std::path::Path::new(suggested);
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first. // The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
const TYPES: [(&str, &str); 4] = const TYPES: [(&str, &str); 4] =
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")]; [("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e)); let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
let ext = ext.unwrap_or_else(|| "xlsx".to_string()); let ext = ext.unwrap_or_else(|| "xlsx".to_string());
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}")); let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) { for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
+2 -2
View File
@@ -17,7 +17,7 @@ pub fn discover() -> Discover {
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new()); static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
pub fn finish_pending_jobs() { pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
for worker in workers { for worker in workers {
if worker.join().is_err() { if worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown"); log::error!("the print worker stopped unexpectedly during shutdown");
@@ -45,7 +45,7 @@ pub fn submit() -> Submit {
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string())); let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
}); });
if let Ok(worker) = worker { if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
jobs.retain(|job| !job.is_finished()); jobs.retain(|job| !job.is_finished());
jobs.push(worker); jobs.push(worker);
} }
+1 -1
View File
@@ -425,7 +425,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max); let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal { if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4); let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
let y2labels = y2.map(ValAxis::labels); let y2labels = y2.map(|a| a.labels());
let right = match (&y2labels, &cat) { let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3), (Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2), (None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
+1 -1
View File
@@ -77,7 +77,7 @@ fn sample(kind: ChartKind) -> ChartData {
ChartData { ChartData {
category_levels: vec![], category_levels: vec![],
palette: vec![], palette: vec![],
categories: cats.iter().map(ToString::to_string).collect(), categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![ series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1), series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
b, b,
+1 -1
View File
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) { if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
chars.next(); chars.next();
letters += 1; letters += 1;
while chars.peek().is_some_and(char::is_ascii_digit) { while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
chars.next(); chars.next();
} }
} }
+2 -2
View File
@@ -245,7 +245,7 @@ impl Value {
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }), Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
Value::Text(t) => Ok(t.to_string()), Value::Text(t) => Ok(t.to_string()),
Value::Error(e) => Err(*e), Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())), Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
} }
} }
/// Coercion to a boolean as `IF` does it. /// Coercion to a boolean as `IF` does it.
@@ -264,7 +264,7 @@ impl Value {
} }
} }
Value::Error(e) => Err(*e), Value::Error(e) => Err(*e),
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)), Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
} }
} }
/// Display text with General formatting (for logs, CSV and tests). /// Display text with General formatting (for logs, CSV and tests).
+1 -1
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use gridcraft_core::{CellRef, RangeRef, Value, compare}; use gridcraft_core::{CellRef, RangeRef, Value, compare};
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds}; use gridcraft_model::*;
/// The conditional look of one cell. /// The conditional look of one cell.
#[derive(Clone, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
+1 -1
View File
@@ -941,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
return Err(EngineError::Other("Nothing to import.".into())); return Err(EngineError::Other("Nothing to import.".into()));
} }
let name = str_param(p, "sheet").map(str::to_string).or_else(|| { let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect())) str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
}); });
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?; let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1); let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
+3 -3
View File
@@ -69,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls); || matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
let title = if imported { let title = if imported {
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book")) format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
} else { } else {
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string() std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
}; };
// An import has no save target: ordinary Save must ask for a new destination. Neither does a // An import has no save target: ordinary Save must ask for a new destination. Neither does a
// workbook decrypted with its password, so Save never replaces the encrypted original with // workbook decrypted with its password, so Save never replaces the encrypted original with
@@ -114,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
}; };
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the // A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
// document keeps its file and its unsaved changes. // document keeps its file and its unsaved changes.
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
return super::print::export_pdf(s, &json!({"path": path})); return super::print::export_pdf(s, &json!({"path": path}));
} }
let d = s.doc_mut()?; let d = s.doc_mut()?;
+1 -1
View File
@@ -165,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
test_commands().iter().find(|c| c.id == id).copied() test_commands().iter().find(|c| c.id == id).copied()
} }
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new()); static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
/// Test-only: register an extra command so UI integration tests can prove a pending /// Test-only: register an extra command so UI integration tests can prove a pending
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs). /// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
+1 -1
View File
@@ -253,7 +253,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
} }
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> { fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
match v.map(str::to_ascii_lowercase) { match v.map(|x| x.to_ascii_lowercase()) {
Some(a) if a == "rows" || a == "row" => Ok("rows"), Some(a) if a == "rows" || a == "row" => Ok("rows"),
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"), Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"), Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
+1 -1
View File
@@ -617,7 +617,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
i += rest.chars().count() + 1; i += rest.chars().count() + 1;
} }
d if d.is_ascii_digit() => { d if d.is_ascii_digit() => {
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect(); let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
i += digits.len() - 1; i += digits.len() - 1;
if let Ok(v) = digits.parse::<f32>() { if let Ok(v) = digits.parse::<f32>() {
sec.size = Some(v.clamp(1.0, 409.0)); sec.size = Some(v.clamp(1.0, 409.0));
+17 -29
View File
@@ -7,10 +7,7 @@ use gridcraft_formula::adjust::{Axis, Edit};
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility}; use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
use serde_json::{Value as Json, json}; use serde_json::{Value as Json, json};
use super::{ use super::*;
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
str_param, target_range, target_sheet, u32_param,
};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -273,29 +270,6 @@ fn cancel_copy_mode(s: &mut Session) {
} }
} }
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
if at == 0 {
return;
}
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
Ok(())
}
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> { fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
let (at, count) = lines(s, p, axis)?; let (at, count) = lines(s, p, axis)?;
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS }; let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
@@ -305,7 +279,13 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
} }
check_lines_protection(s, p, axis, at, count, true)?; check_lines_protection(s, p, axis, at, count, true)?;
let sheet = target_sheet(s, p)?; let sheet = target_sheet(s, p)?;
check_push_off_sheet(s, sheet, axis, at, count, limit)?; // Refuse to push data off the sheet.
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
if last >= at && last as u64 + count as u64 >= limit as u64 {
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
}
}
let out = edit(s, |cx| { let out = edit(s, |cx| {
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default(); let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
cx.protection_checked = true; cx.protection_checked = true;
@@ -317,7 +297,15 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
} }
shift_sheet_features(sh, axis, at, count, true); shift_sheet_features(sh, axis, at, count, true);
// New lines take the format of the line before (Excel's "Format Same As Above"). // New lines take the format of the line before (Excel's "Format Same As Above").
inherit_line_format(sh, axis, at, count); if at > 0 {
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
if let Some(info) = prev {
for i in at..at + count {
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
}
}
}
} }
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count }); rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
cx.structural = true; cx.structural = true;
+24 -24
View File
@@ -28,10 +28,8 @@ use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook}; use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
use serde_json::{Value as Json, json}; use serde_json::{Value as Json, json};
use super::check_arrays;
use super::whatif::Trial; use super::whatif::Trial;
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param}; use super::*;
use gridcraft_core::split_sheet;
const CMD: &str = "data.solver"; const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport"; const REPORT_CMD: &str = "data.solverReport";
@@ -1707,26 +1705,6 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
3 3
} }
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
}
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines { fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
let m = inp.model; let m = inp.model;
let mut trial = Trial::new(d); let mut trial = Trial::new(d);
@@ -1790,7 +1768,29 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
Rhs::None => vec![], Rhs::None => vec![],
}; };
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() { for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k)); let (formula, status, slack) = match (c.rel, &c.rhs) {
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
(rel, rhs_cells) => {
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
let rhs_text = match rhs_cells {
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
_ => gridcraft_core::number_to_text(b),
};
let slack = (v - b).abs();
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
let slack = if binding { 0.0 } else { slack };
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
}
};
rows.push(vec![
Value::text(abs_ref(*cell)),
Value::text(cell_name(sh, *cell)),
num(*v),
Value::text(formula),
Value::text(status),
num(slack),
]);
} }
} }
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows); table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
+2 -2
View File
@@ -527,9 +527,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1). /// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> { fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
let chars: Vec<char> = t.chars().collect(); let chars: Vec<char> = t.chars().collect();
let end = chars.iter().rposition(char::is_ascii_digit)?; let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
let mut start = end; let mut start = end;
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) { while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
start -= 1; start -= 1;
} }
let digits: String = chars.get(start..=end)?.iter().collect(); let digits: String = chars.get(start..=end)?.iter().collect();
+3 -3
View File
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
/// A CSV opens as a sheet named after the file (like Excel). /// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) { fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1"); let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string(); let clean = clean.trim_matches('\'').to_string();
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
// An unknown or missing extension is refused rather than written as an XLSX under that name: // An unknown or missing extension is refused rather than written as an XLSX under that name:
// a typo like `.cvs` would otherwise produce a file no other app recognises. // a typo like `.cvs` would otherwise produce a file no other app recognises.
let Some(kind) = FileKind::from_path(path) else { let Some(kind) = FileKind::from_path(path) else {
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path); let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html."))); return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
}; };
save_bytes_as(wb, path, kind) save_bytes_as(wb, path, kind)
@@ -239,7 +239,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
// the VBA project, so any other name saves without it, as Excel does. // the VBA project, so any other name saves without it, as Excel does.
FileKind::Xlsx => { FileKind::Xlsx => {
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default(); let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string())) gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
} }
FileKind::Xls => { FileKind::Xls => {
+2 -5
View File
@@ -601,11 +601,8 @@ impl Session {
let result = match result { let result = match result {
Ok(r) => r, Ok(r) => r,
Err(p) => { Err(p) => {
let msg = p let msg =
.downcast_ref::<&str>() p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
// Restore the workbook and its history as they were, and drop the undo groups the // Restore the workbook and its history as they were, and drop the undo groups the
// command opened. // command opened.
self.groups.retain(|g| g.id < first_group); self.groups.retain(|g| g.id < first_group);
+1 -4
View File
@@ -11,10 +11,7 @@ use std::collections::{HashMap, HashSet};
use gridcraft_core::date::datetime_from_serial; use gridcraft_core::date::datetime_from_serial;
pub use gridcraft_core::quote_sheet; pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet}; use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
use gridcraft_model::{ use gridcraft_model::*;
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
};
use crate::pivot_calc::CalcSet; use crate::pivot_calc::CalcSet;
+1 -1
View File
@@ -2,7 +2,7 @@
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip). //! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
use gridcraft_core::{CellRef, RangeRef}; use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook}; use gridcraft_model::*;
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2). /// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
pub fn parse_style_name(name: &str) -> (&'static str, u32) { pub fn parse_style_name(name: &str) -> (&'static str, u32) {
@@ -232,7 +232,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
Ok(Ok(v)) => Ok(v), Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(fail(step, kind, e)), Ok(Err(e)) => Err(fail(step, kind, e)),
Err(p) => { Err(p) => {
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default(); let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(fail(step, kind, format!("panicked: {msg}"))) Err(fail(step, kind, format!("panicked: {msg}")))
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef}; use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor}; use crate::ast::*;
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`. /// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr { pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
+1 -1
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix}; use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
use gridcraft_core::{CellError, MAX_ROWS}; use gridcraft_core::{CellError, MAX_ROWS};
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; use crate::ast::*;
use crate::lexer::{Tok, Token, tokenize_locale}; use crate::lexer::{Tok, Token, tokenize_locale};
#[derive(Clone, Debug, PartialEq, thiserror::Error)] #[derive(Clone, Debug, PartialEq, thiserror::Error)]
+1 -1
View File
@@ -4,7 +4,7 @@ use std::fmt::Write;
use gridcraft_core::{CellRef, col_to_letters, number_to_text}; use gridcraft_core::{CellRef, col_to_letters, number_to_text};
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; use crate::ast::*;
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes}; pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
+46 -58
View File
@@ -123,60 +123,6 @@ impl Criterion {
Criterion { op, operand, bare_empty: false } Criterion { op, operand, bare_empty: false }
} }
fn matches_blank(&self, v: &Value) -> bool {
let blank = matches!(v, Value::Empty);
match self.op {
Op::Eq => blank,
Op::Ne => !blank,
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
// empty string never matches ("<=" and ">=" count it in neither).
_ => match v {
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
_ => false,
},
}
}
fn matches_number(&self, v: &Value, n: f64) -> bool {
let cell = match v {
Value::Number(x) => Some(*x),
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
_ => None,
};
match cell {
Some(x) => cmp_op(self.op, compare_numbers(x, n)),
None => self.op == Op::Ne,
}
}
fn matches_text(&self, v: &Value, pat: &str) -> bool {
match self.op {
Op::Eq | Op::Ne => {
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
let cell_text = match v {
Value::Text(t) => Some(t.to_string()),
_ => None,
};
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
if self.op == Op::Eq { m } else { !m }
}
_ => match v {
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
_ => false,
},
}
}
fn matches_cut_text(&self, v: &Value, kept: &str) -> bool {
match self.op {
Op::Eq => false,
Op::Ne => true,
Op::Lt | Op::Le => matches!(v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
Op::Gt | Op::Ge => matches!(v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
}
}
/// Whether a cell value satisfies the criterion. /// Whether a cell value satisfies the criterion.
pub(crate) fn matches(&self, v: &Value) -> bool { pub(crate) fn matches(&self, v: &Value) -> bool {
let v = match v { let v = match v {
@@ -187,8 +133,31 @@ impl Criterion {
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty()); return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
} }
match &self.operand { match &self.operand {
Operand::Blank => self.matches_blank(&v), Operand::Blank => {
Operand::Number(n) => self.matches_number(&v, *n), let blank = matches!(v, Value::Empty);
match self.op {
Op::Eq => blank,
Op::Ne => !blank,
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
// empty string never matches ("<=" and ">=" count it in neither).
_ => match &v {
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
_ => false,
},
}
}
Operand::Number(n) => {
let cell = match &v {
Value::Number(x) => Some(*x),
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
_ => None,
};
match cell {
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
None => self.op == Op::Ne,
}
}
Operand::Bool(b) => match &v { Operand::Bool(b) => match &v {
Value::Bool(x) => cmp_op(self.op, x.cmp(b)), Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
_ => self.op == Op::Ne, _ => self.op == Op::Ne,
@@ -197,8 +166,27 @@ impl Criterion {
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())), Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
_ => self.op == Op::Ne, _ => self.op == Op::Ne,
}, },
Operand::Text(pat) => self.matches_text(&v, pat), Operand::Text(pat) => match self.op {
Operand::CutText(kept) => self.matches_cut_text(&v, kept), Op::Eq | Op::Ne => {
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
let cell_text = match &v {
Value::Text(t) => Some(t.to_string()),
_ => None,
};
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
if self.op == Op::Eq { m } else { !m }
}
_ => match &v {
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
_ => false,
},
},
Operand::CutText(kept) => match self.op {
Op::Eq => false,
Op::Ne => true,
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
},
} }
} }
} }
+1 -1
View File
@@ -742,7 +742,7 @@ fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R<Cx>) -> R<Value> {
fmt_complex(f(x, y)?, s.unwrap_or('i')) fmt_complex(f(x, y)?, s.unwrap_or('i'))
} }
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
im_binary(a, Cx::div) im_binary(a, |x, y| x.div(y))
} }
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> { fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
im_binary(a, |x, y| Ok(x.sub(y))) im_binary(a, |x, y| Ok(x.sub(y)))
+2 -2
View File
@@ -278,7 +278,7 @@ fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
Aggregate::Max => xs.iter().copied().reduce(f64::max), Aggregate::Max => xs.iter().copied().reduce(f64::max),
Aggregate::Min => xs.iter().copied().reduce(f64::min), Aggregate::Min => xs.iter().copied().reduce(f64::min),
Aggregate::Median => { Aggregate::Median => {
xs.sort_by(f64::total_cmp); xs.sort_by(|a, b| a.total_cmp(b));
let k = xs.len() / 2; let k = xs.len() / 2;
let hi = xs.get(k).copied()?; let hi = xs.get(k).copied()?;
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) } if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
@@ -415,7 +415,7 @@ impl Timeline {
return None; return None;
} }
let mut sorted = times.to_vec(); let mut sorted = times.to_vec();
sorted.sort_by(f64::total_cmp); sorted.sort_by(|a, b| a.total_cmp(b));
sorted.dedup(); sorted.dedup();
let axis = detect_axis(&sorted, sys).ok()?; let axis = detect_axis(&sorted, sys).ok()?;
let last = axis.position(*sorted.last()?)?.round(); let last = axis.position(*sorted.last()?)?.round();
+1 -1
View File
@@ -201,7 +201,7 @@ impl FnSpec {
pub(crate) const VAR: usize = usize::MAX; pub(crate) const VAR: usize = usize::MAX;
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) { fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
static REG: OnceLock<(Vec<FnSpec>, HashMap<&str, usize>)> = OnceLock::new(); static REG: OnceLock<(Vec<FnSpec>, HashMap<&'static str, usize>)> = OnceLock::new();
REG.get_or_init(|| { REG.get_or_init(|| {
let mut v: Vec<FnSpec> = Vec::new(); let mut v: Vec<FnSpec> = Vec::new();
for list in [ for list in [
+1 -1
View File
@@ -43,7 +43,7 @@ fn var_p(x: &[f64]) -> R<f64> {
} }
fn sorted(mut v: Vec<f64>) -> Vec<f64> { fn sorted(mut v: Vec<f64>) -> Vec<f64> {
v.sort_by(f64::total_cmp); v.sort_by(|a, b| a.total_cmp(b));
v v
} }
+5 -5
View File
@@ -523,7 +523,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
let mut j = t.len(); let mut j = t.len();
while j > 0 { while j > 0 {
let best = let best =
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
match best { match best {
Some(l) => { Some(l) => {
out.push((j - l, j)); out.push((j - l, j));
@@ -537,7 +537,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
} }
let mut i = 0; let mut i = 0;
while i < t.len() { while i < t.len() {
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max(); let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
match best { match best {
Some(l) => { Some(l) => {
out.push((i, i + l)); out.push((i, i + l));
@@ -588,7 +588,7 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
} }
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> { fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
if delims.iter().all(Vec::is_empty) { if delims.iter().all(|d| d.is_empty()) {
return vec![s.to_vec()]; return vec![s.to_vec()];
} }
let mut out = Vec::new(); let mut out = Vec::new();
@@ -748,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
let mut i = 0; let mut i = 0;
while i < c.len() { while i < c.len() {
let ch = c[i]; let ch = c[i];
if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) { if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
let mut j = i + 1; let mut j = i + 1;
while c.get(j).is_some_and(char::is_ascii_digit) { while c.get(j).is_some_and(|d| d.is_ascii_digit()) {
j += 1; j += 1;
} }
out.push_str("${"); out.push_str("${");
+3 -9
View File
@@ -7,10 +7,7 @@ use gridcraft_core::{CellRef, RangeRef, Value};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture}; use crate::cell::{Cell, CellPicture};
use crate::features::{ use crate::features::*;
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
Validation,
};
use crate::store::CellStore; use crate::store::CellStore;
use crate::style::{Color, StyleId}; use crate::style::{Color, StyleId};
@@ -625,11 +622,8 @@ mod tests {
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats. // CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
mod picture_map { mod picture_map {
use crate::cell::CellPicture; use super::*;
use gridcraft_core::CellRef; use serde::{Deserializer, Serializer};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::sync::Arc;
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> { pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
map.iter().collect::<Vec<_>>().serialize(serializer) map.iter().collect::<Vec<_>>().serialize(serializer)
} }
+1 -1
View File
@@ -525,7 +525,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
let s = if k + 1 == count { let s = if k + 1 == count {
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default() dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
} else { } else {
dc.get(k).map(ToString::to_string).unwrap_or_default() dc.get(k).map(|c| c.to_string()).unwrap_or_default()
}; };
let s = if s.is_empty() { let s = if s.is_empty() {
match toks.get(ti) { match toks.get(ti) {
+2 -2
View File
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
'ž' => 'z', 'ž' => 'z',
'Œ' => 'W', 'Œ' => 'W',
'œ' => 'm', 'œ' => 'm',
'‘'..='‚' => '\'', '‘' | '’' | '‚' => '\'',
'“'..='„' => '"', '“' | '”' | '„' => '"',
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0', '–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
'\u{a0}' => ' ', '\u{a0}' => ' ',
'·' | '•' => '.', '·' | '•' => '.',
+3 -3
View File
@@ -352,8 +352,8 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
/// File name and folder of a recent path, split as text. /// File name and folder of a recent path, split as text.
fn split_path(path: &str) -> (String, String) { fn split_path(path: &str) -> (String, String) {
let p = std::path::Path::new(path); let p = std::path::Path::new(path);
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string(); let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string(); let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
(name, folder) (name, folder)
} }
@@ -888,7 +888,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
_ => return false, _ => return false,
}; };
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
if let Some(pick) = &app.services.pick_save { if let Some(pick) = &app.services.pick_save {
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false }; let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
// The picker may suggest a workbook extension; the export writes the chosen format. // The picker may suggest a workbook extension; the export writes the chosen format.
+2 -2
View File
@@ -343,7 +343,7 @@ impl Dialog {
// The file rides along in the values (`path` on the desktop, `name` and `base64` // The file rides along in the values (`path` on the desktop, `name` and `base64`
// on the web) and goes back to `file.open` with the password. // on the web) and goes back to `file.open` with the password.
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default(); let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("This workbook").to_string(); let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
// An empty password from the start, so OK on the untouched field says "incorrect" // An empty password from the start, so OK on the untouched field says "incorrect"
// rather than asking for the password again. // rather than asking for the password again.
let mut values = params.clone(); let mut values = params.clone();
@@ -1369,7 +1369,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
&& let Some(path) = pick() && let Some(path) = pick()
{ {
d.values.remove("base64"); d.values.remove("base64");
let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(""); let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
d.values.insert("alt".into(), json!(alt)); d.values.insert("alt".into(), json!(alt));
d.values.insert("path".into(), json!(path)); d.values.insert("path".into(), json!(path));
} }
+6 -8
View File
@@ -78,7 +78,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300). /// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn style_weight(style: &str) -> f32 { fn style_weight(style: &str) -> f32 {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect(); let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
const TABLE: &[(&str, f32)] = &[ const TABLE: &[(&str, f32)] = &[
("hairline", 100.0), ("hairline", 100.0),
("thin", 100.0), ("thin", 100.0),
@@ -107,7 +107,7 @@ impl Face {
/// Whether a style name reads as italic or oblique. /// Whether a style name reads as italic or oblique.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn style_italic(style: &str) -> bool { fn style_italic(style: &str) -> bool {
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect(); let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
s.contains("italic") || s.contains("oblique") s.contains("italic") || s.contains("oblique")
} }
@@ -165,10 +165,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
stack.push(path); stack.push(path);
continue; continue;
} }
let is_font = path let is_font =
.extension() path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) { if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
continue; continue;
} }
@@ -368,12 +366,12 @@ fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
/// Record the role families a freshly built [`egui::FontDefinitions`] bound. /// Record the role families a freshly built [`egui::FontDefinitions`] bound.
pub fn set_bound(keys: impl IntoIterator<Item = String>) { pub fn set_bound(keys: impl IntoIterator<Item = String>) {
*bound().write().unwrap_or_else(std::sync::PoisonError::into_inner) = keys.into_iter().collect(); *bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
} }
/// Is this exact role family bound to font data? /// Is this exact role family bound to font data?
pub fn is_bound(role: &str) -> bool { pub fn is_bound(role: &str) -> bool {
bound().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(role) bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
} }
/// Serializes tests that mutate the process-wide bound-family registry. /// Serializes tests that mutate the process-wide bound-family registry.
+2 -1
View File
@@ -2200,8 +2200,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
} }
} }
return; return;
} else {
app.selected_chart = None;
} }
app.selected_chart = None;
} }
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's // A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
// under it; a right press that wobbles into a drag counts too (#2067). // under it; a right press that wobbles into a drag counts too (#2067).
+1 -1
View File
@@ -567,7 +567,7 @@ impl SheetApp {
return; return;
} }
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string(); let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
let Some(pick) = &self.services.pick_save else { return }; let Some(pick) = &self.services.pick_save else { return };
let Some(path) = pick(&format!("{stem}.pdf")) else { return }; let Some(path) = pick(&format!("{stem}.pdf")) else { return };
// The picker may suggest a workbook extension; the export writes a PDF. // The picker may suggest a workbook extension; the export writes a PDF.
+190 -162
View File
@@ -14,11 +14,6 @@ use std::sync::Arc;
use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
/// The light or dark value of a colour, by mode.
fn pick<T>(dark: bool, light: T, dark_value: T) -> T {
if dark { dark_value } else { light }
}
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens { pub struct Tokens {
pub dark: bool, pub dark: bool,
@@ -134,70 +129,110 @@ const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
impl Tokens { impl Tokens {
pub fn light() -> Tokens { pub fn light() -> Tokens {
Tokens::palette(false) Tokens {
dark: false,
window: Color32::from_rgb(0xF3, 0xF3, 0xF3),
ribbon: Color32::from_rgb(0xFF, 0xFF, 0xFF),
ribbon_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
text: Color32::from_rgb(0x24, 0x24, 0x24),
text_dim: Color32::from_rgb(0x61, 0x61, 0x61),
text_disabled: Color32::from_rgb(0xB0, 0xB0, 0xB0),
accent: Color32::from_rgb(0x10, 0x7C, 0x41),
accent_dark: Color32::from_rgb(0x0B, 0x5E, 0x31),
accent_soft: Color32::from_rgb(0xDF, 0xF1, 0xE6),
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD),
separator: Color32::from_rgb(0xE0, 0xE0, 0xE0),
grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0xDE, 0xDE, 0xDE),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8),
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
header_line: Color32::from_rgb(0xD6, 0xD6, 0xD6),
header_sel_bg: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_sel_text: Color32::from_rgb(0x0B, 0x5E, 0x31),
header_all_bg: Color32::from_rgb(0xC8, 0xE6, 0xD3),
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: Color32::WHITE,
input_border: Color32::from_rgb(0xD1, 0xD1, 0xD1),
tab_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
tab_active: Color32::WHITE,
status_bar: Color32::from_rgb(0xF3, 0xF3, 0xF3),
cell_text: Color32::BLACK,
menu_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(40),
danger: Color32::from_rgb(0xC4, 0x2B, 0x1C),
error_check_green: Color32::from_rgb(0x1E, 0x8E, 0x3E),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK,
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),
on_accent: Color32::WHITE,
cat: [rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)],
paper: Color32::WHITE,
ref_colors: SHEET_REF_COLORS,
sheet_ref_colors: SHEET_REF_COLORS,
syntax: LIGHT_SYNTAX,
warning: rgb(0xF2C32E),
on_warning: rgb(0x1F1F1F),
sheet_control_bg: SHEET_CONTROL_BG,
sheet_control_border: SHEET_CONTROL_BORDER,
sheet_control_text: SHEET_CONTROL_TEXT,
sheet_track: SHEET_TRACK,
}
} }
pub fn dark() -> Tokens { pub fn dark() -> Tokens {
Tokens::palette(true)
}
/// One palette for both modes: each colour is written once, with its light and dark values.
fn palette(dark: bool) -> Tokens {
Tokens { Tokens {
dark, dark: true,
window: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)), window: Color32::from_rgb(0x29, 0x29, 0x29),
ribbon: pick(dark, Color32::from_rgb(0xFF, 0xFF, 0xFF), Color32::from_rgb(0x33, 0x33, 0x33)), ribbon: Color32::from_rgb(0x33, 0x33, 0x33),
ribbon_border: pick(dark, Color32::from_rgb(0xE3, 0xE3, 0xE3), Color32::from_rgb(0x40, 0x40, 0x40)), ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40),
text: pick(dark, Color32::from_rgb(0x24, 0x24, 0x24), Color32::from_rgb(0xEE, 0xEE, 0xEE)), text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
text_dim: pick(dark, Color32::from_rgb(0x61, 0x61, 0x61), Color32::from_rgb(0xB0, 0xB0, 0xB0)), text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
text_disabled: pick(dark, Color32::from_rgb(0xB0, 0xB0, 0xB0), Color32::from_rgb(0x70, 0x70, 0x70)), text_disabled: Color32::from_rgb(0x70, 0x70, 0x70),
accent: pick(dark, Color32::from_rgb(0x10, 0x7C, 0x41), Color32::from_rgb(0x5C, 0xC5, 0x87)), accent: Color32::from_rgb(0x5C, 0xC5, 0x87),
accent_dark: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x3F, 0xA8, 0x6C)), accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C),
accent_soft: pick(dark, Color32::from_rgb(0xDF, 0xF1, 0xE6), Color32::from_rgb(0x23, 0x45, 0x31)), accent_soft: Color32::from_rgb(0x23, 0x45, 0x31),
hover: pick(dark, Color32::from_rgb(0xEB, 0xEB, 0xEB), Color32::from_rgb(0x42, 0x42, 0x42)), hover: Color32::from_rgb(0x42, 0x42, 0x42),
pressed: pick(dark, Color32::from_rgb(0xDD, 0xDD, 0xDD), Color32::from_rgb(0x4D, 0x4D, 0x4D)), pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
separator: pick(dark, Color32::from_rgb(0xE0, 0xE0, 0xE0), Color32::from_rgb(0x48, 0x48, 0x48)), separator: Color32::from_rgb(0x48, 0x48, 0x48),
grid_bg: Color32::WHITE, grid_bg: Color32::WHITE,
desk: pick(dark, Color32::from_rgb(0xDE, 0xDE, 0xDE), Color32::from_rgb(0x3A, 0x3A, 0x3A)), desk: Color32::from_rgb(0x3A, 0x3A, 0x3A),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: pick(dark, Color32::from_rgb(0xF8, 0xF8, 0xF8), Color32::from_rgb(0x30, 0x30, 0x30)), header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
header_text: pick(dark, Color32::from_rgb(0x42, 0x42, 0x42), Color32::from_rgb(0xCC, 0xCC, 0xCC)), header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
header_line: pick(dark, Color32::from_rgb(0xD6, 0xD6, 0xD6), Color32::from_rgb(0x45, 0x45, 0x45)), header_line: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_bg: pick(dark, Color32::from_rgb(0xE1, 0xE1, 0xE1), Color32::from_rgb(0x45, 0x45, 0x45)), header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)), header_sel_text: Color32::from_rgb(0x6F, 0xD3, 0x9A),
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)), header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
sel_fill: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24)), sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41), sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)), input_bg: Color32::from_rgb(0x1F, 0x1F, 0x1F),
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)), input_border: Color32::from_rgb(0x50, 0x50, 0x50),
tab_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)), tab_bar: Color32::from_rgb(0x29, 0x29, 0x29),
tab_active: pick(dark, Color32::WHITE, Color32::from_rgb(0x3A, 0x3A, 0x3A)), tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A),
status_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)), status_bar: Color32::from_rgb(0x29, 0x29, 0x29),
cell_text: Color32::BLACK, cell_text: Color32::BLACK,
menu_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x30, 0x30, 0x30)), menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: pick(dark, Color32::from_black_alpha(40), Color32::from_black_alpha(90)), shadow: Color32::from_black_alpha(90),
danger: pick(dark, Color32::from_rgb(0xC4, 0x2B, 0x1C), Color32::from_rgb(0xFF, 0x95, 0x85)), danger: Color32::from_rgb(0xFF, 0x95, 0x85),
error_check_green: pick(dark, Color32::from_rgb(0x1E, 0x8E, 0x3E), Color32::from_rgb(0x4C, 0xC2, 0x6B)), error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05), warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK, warning_ink: Color32::BLACK,
notice_warn_bg: pick(dark, Color32::from_rgb(0xFF, 0xF4, 0xCE), Color32::from_rgb(0x4A, 0x3F, 0x1E)), notice_warn_bg: Color32::from_rgb(0x4A, 0x3F, 0x1E),
notice_warn_border: pick(dark, Color32::from_rgb(0xF0, 0xC3, 0x6E), Color32::from_rgb(0x7A, 0x67, 0x30)), notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30),
notice_info_bg: pick(dark, Color32::from_rgb(0xEA, 0xF2, 0xFB), Color32::from_rgb(0x24, 0x33, 0x44)), notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44),
notice_info_border: pick(dark, Color32::from_rgb(0xB9, 0xD2, 0xEC), Color32::from_rgb(0x3B, 0x55, 0x72)), notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72),
on_accent: pick(dark, Color32::WHITE, rgb(0x0F2A1A)), on_accent: rgb(0x0F2A1A),
cat: pick( cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
dark, paper: rgb(0x454545),
[rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)], ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
[rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
),
paper: pick(dark, Color32::WHITE, rgb(0x454545)),
ref_colors: pick(
dark,
SHEET_REF_COLORS,
[rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
),
sheet_ref_colors: SHEET_REF_COLORS, sheet_ref_colors: SHEET_REF_COLORS,
syntax: pick(dark, LIGHT_SYNTAX, DARK_SYNTAX), syntax: DARK_SYNTAX,
warning: rgb(0xF2C32E), warning: rgb(0xF2C32E),
on_warning: rgb(0x1F1F1F), on_warning: rgb(0x1F1F1F),
sheet_control_bg: SHEET_CONTROL_BG, sheet_control_bg: SHEET_CONTROL_BG,
@@ -347,7 +382,7 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
/// looked for again next time. /// looked for again next time.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn font_file(path: &str, index: u32) -> Option<&'static [u8]> { fn font_file(path: &str, index: u32) -> Option<&'static [u8]> {
static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &[u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new()); static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(&bytes) = files.get(path) { if let Some(&bytes) = files.get(path) {
return font_parses(bytes, index).then_some(bytes); return font_parses(bytes, index).then_some(bytes);
@@ -387,12 +422,6 @@ fn home_fonts(file: &str) -> String {
format!("{home}/Library/Fonts/{file}") format!("{home}/Library/Fonts/{file}")
} }
/// Every file in `files` under every directory in `dirs`, directory first, in the same order a
/// nested loop over the directories would add them.
fn in_dirs(dirs: &[&str], files: &[&str]) -> Vec<String> {
dirs.iter().flat_map(|d| files.iter().map(move |f| format!("{d}/{f}"))).collect()
}
/// Candidate font files per role, in preference order. /// Candidate font files per role, in preference order.
fn candidates(role: &str) -> Vec<(String, u32)> { fn candidates(role: &str) -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts"; let mac_sys = "/System/Library/Fonts";
@@ -407,11 +436,11 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("{mac_sys}/SFNS.ttf"), 0); add(format!("{mac_sys}/SFNS.ttf"), 0);
add(format!("{mac_sys}/HelveticaNeue.ttc"), 0); add(format!("{mac_sys}/HelveticaNeue.ttc"), 0);
add(format!("{win}\\segoeui.ttf"), 0); add(format!("{win}\\segoeui.ttf"), 0);
for p in in_dirs( for d in lin {
&lin, add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0);
&["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"], add(format!("{d}/noto/NotoSans-Regular.ttf"), 0);
) { add(format!("{d}/dejavu/DejaVuSans.ttf"), 0);
add(p, 0); add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0);
} }
// Fedora keeps each family in its own package folder (#54). // Fedora keeps each family in its own package folder (#54).
add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0); add("/usr/share/fonts/abattis-cantarell-fonts/Cantarell-Regular.otf".into(), 0);
@@ -422,8 +451,10 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
"ui-bold" => { "ui-bold" => {
add(format!("{mac_sys}/HelveticaNeue.ttc"), 1); add(format!("{mac_sys}/HelveticaNeue.ttc"), 1);
add(format!("{win}\\segoeuib.ttf"), 0); add(format!("{win}\\segoeuib.ttf"), 0);
for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) { for d in lin {
add(p, 0); add(format!("{d}/noto/NotoSans-Bold.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSans-Bold.ttf"), 0);
add(format!("{d}/liberation/LiberationSans-Bold.ttf"), 0);
} }
add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0); add("/usr/share/fonts/google-noto/NotoSans-Bold.ttf".into(), 0);
add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0); add("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf".into(), 0);
@@ -466,16 +497,18 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("/Library/Fonts/{calibri_mac}"), 0); add(format!("/Library/Fonts/{calibri_mac}"), 0);
add(home_fonts(calibri_mac), 0); add(home_fonts(calibri_mac), 0);
add(format!("{win}\\{calibri_win}"), 0); add(format!("{win}\\{calibri_win}"), 0);
for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) { for d in lin {
add(p, 0); add(format!("{d}/crosextra/{carlito}"), 0);
add(format!("{d}/carlito/{carlito}"), 0);
} }
// Fedora keeps each family in its own package folder, as for the UI fonts (#54). // Fedora keeps each family in its own package folder, as for the UI fonts (#54).
add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0); add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
add(home_fonts(carlito), 0); add(home_fonts(carlito), 0);
add(format!("{mac_sup}/{arial_mac}"), 0); add(format!("{mac_sup}/{arial_mac}"), 0);
add(format!("{win}\\{arial_win}"), 0); add(format!("{win}\\{arial_win}"), 0);
for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) { for d in lin {
add(p, 0); add(format!("{d}/liberation/{lib}"), 0);
add(format!("{d}/dejavu/{dejavu}"), 0);
} }
add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0); add(format!("/usr/share/fonts/liberation-sans-fonts/{lib}"), 0);
add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0); add(format!("/usr/share/fonts/dejavu-sans-fonts/{dejavu}"), 0);
@@ -483,8 +516,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
"serif" => { "serif" => {
add(format!("{mac_sup}/Times New Roman.ttf"), 0); add(format!("{mac_sup}/Times New Roman.ttf"), 0);
add(format!("{win}\\times.ttf"), 0); add(format!("{win}\\times.ttf"), 0);
for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) { for d in lin {
add(p, 0); add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSerif.ttf"), 0);
} }
add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0); add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0); add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
@@ -493,14 +527,14 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
add(format!("{mac_sys}/SFNSMono.ttf"), 0); add(format!("{mac_sys}/SFNSMono.ttf"), 0);
add(format!("{mac_sys}/Menlo.ttc"), 0); add(format!("{mac_sys}/Menlo.ttc"), 0);
add(format!("{win}\\consola.ttf"), 0); add(format!("{win}\\consola.ttf"), 0);
for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) { for d in lin {
add(p, 0); add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0);
} }
add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0); add("/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf".into(), 0);
// Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice // Fedora installs DejaVu Sans Mono only on request. Liberation Mono comes with LibreOffice
// (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always. // (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) { for d in lin {
add(p, 0); add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 0);
} }
add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0); add("/usr/share/fonts/liberation-mono-fonts/LiberationMono-Regular.ttf".into(), 0);
add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0); add("/usr/share/fonts/google-noto-vf/NotoSansMono[wght].ttf".into(), 0);
@@ -691,24 +725,6 @@ const LINUX_CJK_DIRS: [&str; 22] = [
"/usr/share/fonts/chromeos/notocjk", "/usr/share/fonts/chromeos/notocjk",
]; ];
/// The Windows fonts folder (`%WINDIR%\Fonts`, or the default install location).
#[cfg(not(target_arch = "wasm32"))]
fn windows_fonts() -> String {
std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into())
}
/// The Noto Sans CJK faces for one locale (`jp`, `sc`, `kr`): the per-locale OTF, then the
/// region-merged TTC at `face`, in every Linux font directory.
#[cfg(not(target_arch = "wasm32"))]
fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> {
let mut v = Vec::new();
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJK{locale}-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), face));
}
v
}
/// Japanese Han + Kana faces (Japanese kanji glyph shapes). /// Japanese Han + Kana faces (Japanese kanji glyph shapes).
/// ///
/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, /// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3,
@@ -717,12 +733,15 @@ fn noto_cjk(locale: &str, face: u32) -> Vec<(String, u32)> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn cjk_han_japanese() -> Vec<(String, u32)> { fn cjk_han_japanese() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts"; let mac = "/System/Library/Fonts";
let win = windows_fonts(); let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = Vec::new(); let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans
v.extend(noto_cjk("jp", 0)); for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
}
v v
} }
@@ -731,15 +750,17 @@ fn cjk_han_japanese() -> Vec<(String, u32)> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn cjk_han_simplified() -> Vec<(String, u32)> { fn cjk_han_simplified() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts"; let mac = "/System/Library/Fonts";
let win = windows_fonts(); let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = vec![ let mut v: Vec<(String, u32)> = Vec::new();
(format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
(format!("{win}\\simsun.ttc"), 0), // SimSun v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
(format!("{win}\\simhei.ttf"), 0), // SimHei v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
(format!("{mac}/Hiragino Sans GB.ttc"), 0), // ships on every recent macOS v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
(format!("{mac}/PingFang.ttc"), 0), // older macOS only; now a downloadable asset v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
]; for d in LINUX_CJK_DIRS {
v.extend(noto_cjk("sc", 2)); v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
}
for d in LINUX_CJK_DIRS { for d in LINUX_CJK_DIRS {
v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0));
v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0));
@@ -753,65 +774,59 @@ fn cjk_han_simplified() -> Vec<(String, u32)> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn cjk_hangul() -> Vec<(String, u32)> { fn cjk_hangul() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts"; let mac = "/System/Library/Fonts";
let win = windows_fonts(); let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let mut v: Vec<(String, u32)> = Vec::new(); let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0));
v.extend(noto_cjk("kr", 1)); for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face
}
v v
} }
/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); /// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai);
/// Linux font directories searched by the Thai and Hebrew faces, in priority order. /// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn linux_noto_dirs() -> [&'static str; 5] { fn thai() -> Vec<(String, u32)> {
[ let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = [
"/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype/noto",
"/usr/share/fonts/opentype/noto", "/usr/share/fonts/opentype/noto",
"/usr/share/fonts/truetype", "/usr/share/fonts/truetype",
"/usr/share/fonts", "/usr/share/fonts",
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
] ];
} let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font. v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
#[cfg(not(target_arch = "wasm32"))] v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> { v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
let w = windows_fonts(); for d in lin {
let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect(); v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
v.extend(mac.iter().map(|p| (p.to_string(), 0))); v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0))); v.push((format!("{d}/garuda/Garuda.ttf"), 0));
v.push((variable.into(), 0)); }
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
v.push(("/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf".into(), 0));
v v
} }
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
#[cfg(not(target_arch = "wasm32"))]
fn thai() -> Vec<(String, u32)> {
script_fonts(
// Leelawadee UI, Tahoma, Microsoft Sans Serif
&["LeelawUI.ttf", "tahoma.ttf", "micross.ttf"],
&["/System/Library/Fonts/Supplemental/Thonburi.ttc", "/System/Library/Fonts/SukhumvitSet.ttc"],
&["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"],
// Fedora ships the variable Noto Sans Thai; its default instance is Regular.
"/usr/share/fonts/google-noto-vf/NotoSansThai[wght].ttf",
)
}
/// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam /// Lao faces. Windows ships Leelawadee UI (and DokChampa on older versions); macOS has Lao Sangam
/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT. /// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn lao() -> Vec<(String, u32)> { fn lao() -> Vec<(String, u32)> {
let mac_sup = "/System/Library/Fonts/Supplemental"; let mac_sup = "/System/Library/Fonts/Supplemental";
let win = windows_fonts(); let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"];
let mut v: Vec<(String, u32)> = vec![ let mut v: Vec<(String, u32)> = Vec::new();
(format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
(format!("{win}\\dokchamp.ttf"), 0), // DokChampa v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
(format!("{mac_sup}/Lao Sangam MN.ttf"), 0), v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
(format!("{mac_sup}/Lao MN.ttc"), 0), v.push((format!("{mac_sup}/Lao MN.ttc"), 0));
];
for d in lin { for d in lin {
v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0)); v.push((format!("{d}/NotoSansLao-Regular.ttf"), 0));
v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0)); v.push((format!("{d}/lao/Phetsarath_OT.ttf"), 0));
@@ -824,20 +839,33 @@ fn lao() -> Vec<(String, u32)> {
/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. /// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn hebrew() -> Vec<(String, u32)> { fn hebrew() -> Vec<(String, u32)> {
script_fonts( let mac_sys = "/System/Library/Fonts";
// David, FrankRuehl, Tahoma let mac_sup = "/System/Library/Fonts/Supplemental";
&["david.ttf", "frank.ttf", "tahoma.ttf"], let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into());
&[ let lin = [
"/System/Library/Fonts/ArialHB.ttc", "/usr/share/fonts/truetype/noto",
"/System/Library/Fonts/SFHebrew.ttf", "/usr/share/fonts/opentype/noto",
"/System/Library/Fonts/Supplemental/Arial Hebrew.ttf", "/usr/share/fonts/truetype",
"/System/Library/Fonts/Supplemental/David.ttf", "/usr/share/fonts",
"/System/Library/Fonts/LucidaGrande.ttc", "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
"/System/Library/Fonts/Lucida Grande.ttc", ];
], let mut v: Vec<(String, u32)> = Vec::new();
&["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"], v.push((format!("{win}\\david.ttf"), 0)); // David
"/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf", v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl
) v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{mac_sys}/ArialHB.ttc"), 0)); // Arial Hebrew
v.push((format!("{mac_sys}/SFHebrew.ttf"), 0));
v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0));
v.push((format!("{mac_sup}/David.ttf"), 0));
v.push((format!("{mac_sys}/LucidaGrande.ttc"), 0));
v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0));
v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0));
v.push((format!("{d}/freefont/FreeSans.ttf"), 0));
}
v.push(("/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf".into(), 0));
v
} }
/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's /// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's
+4 -35
View File
@@ -1,13 +1,7 @@
//! The open, solid polylines the model can represent as editable ink. This is DrawingML //! The open, solid polylines the model can represent as editable ink. This is DrawingML
//! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback. //! geometry, not Office InkML. Unsupported geometry keeps the existing shape-import fallback.
use std::fmt::Write as _; use super::*;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind, Theme};
use super::xfrm;
use crate::tables::EMU_PER_PX;
use crate::xml::El;
const MAX_POINTS: usize = 20_000; const MAX_POINTS: usize = 20_000;
const MAX_COORD: f64 = 1e7; const MAX_COORD: f64 = 1e7;
@@ -30,7 +24,9 @@ fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> {
found.next().is_none().then_some(first) found.next().is_none().then_some(first)
} }
fn validate_shape_obj(obj: &El) -> Option<&El> { /// Do not partially import a path: losing a curve, subpath, transform or line effect changes
/// the drawing. Accept only the explicit subset emitted below, including its line styling.
pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
if obj.name != "sp" if obj.name != "sp"
|| !only_children(obj, &["nvSpPr", "spPr"]) || !only_children(obj, &["nvSpPr", "spPr"])
|| !only_attrs(obj, &["macro", "textlink"]) || !only_attrs(obj, &["macro", "textlink"])
@@ -47,10 +43,6 @@ fn validate_shape_obj(obj: &El) -> Option<&El> {
if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() { if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() {
return None; return None;
} }
Some(sp)
}
fn validate_xfrm(sp: &El, anchor: &Anchor) -> Option<()> {
let xf = one(sp, "xfrm")?; let xf = one(sp, "xfrm")?;
if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) { if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) {
return None; return None;
@@ -70,10 +62,6 @@ fn validate_xfrm(sp: &El, anchor: &Anchor) -> Option<()> {
if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) { if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) {
return None; return None;
} }
Some(())
}
fn validate_cust_geom(sp: &El) -> Option<(&El, i64, i64)> {
let geom = one(sp, "custGeom")?; let geom = one(sp, "custGeom")?;
if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) { if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) {
return None; return None;
@@ -102,10 +90,6 @@ fn validate_cust_geom(sp: &El) -> Option<(&El, i64, i64)> {
{ {
return None; return None;
} }
Some((path, w, h))
}
fn validate_line(sp: &El) -> Option<(i64, Color)> {
let ln = one(sp, "ln")?; let ln = one(sp, "ln")?;
if !only_attrs(ln, &["w", "cap", "cmpd", "algn"]) if !only_attrs(ln, &["w", "cap", "cmpd", "algn"])
|| ln.attr("cap") != Some("flat") || ln.attr("cap") != Some("flat")
@@ -145,10 +129,6 @@ fn validate_line(sp: &El) -> Option<(i64, Color)> {
} }
} }
let line = Color::from_hex(color.attr("val")?)?; let line = Color::from_hex(color.attr("val")?)?;
Some((width, line))
}
fn parse_points(path: &El, anchor: &Anchor, w: i64, h: i64) -> Option<String> {
let mut text = String::new(); let mut text = String::new();
for (i, command) in path.children.iter().enumerate() { for (i, command) in path.children.iter().enumerate() {
if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 { if command.name != if i == 0 { "moveTo" } else { "lnTo" } || !command.attrs.is_empty() || command.children.len() != 1 {
@@ -168,17 +148,6 @@ fn parse_points(path: &El, anchor: &Anchor, w: i64, h: i64) -> Option<String> {
} }
let _ = write!(text, "{},{}", x as f32, y as f32); let _ = write!(text, "{},{}", x as f32, y as f32);
} }
Some(text)
}
/// Do not partially import a path: losing a curve, subpath, transform or line effect changes
/// the drawing. Accept only the explicit subset emitted below, including its line styling.
pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
let sp = validate_shape_obj(obj)?;
validate_xfrm(sp, &anchor)?;
let (path, w, h) = validate_cust_geom(sp)?;
let (width, line) = validate_line(sp)?;
let text = parse_points(path, &anchor, w, h)?;
Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() }) Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() })
} }
+1 -1
View File
@@ -110,7 +110,7 @@ pub enum XlsxKind {
impl XlsxKind { impl XlsxKind {
/// The kind a file name's extension asks for; anything else is a plain workbook. /// The kind a file name's extension asks for; anything else is a plain workbook.
pub fn from_path(path: &str) -> XlsxKind { pub fn from_path(path: &str) -> XlsxKind {
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
match ext.as_str() { match ext.as_str() {
"xlsm" => XlsxKind::MacroWorkbook, "xlsm" => XlsxKind::MacroWorkbook,
"xltx" => XlsxKind::Template, "xltx" => XlsxKind::Template,
+1 -1
View File
@@ -647,7 +647,7 @@ fn group_items(sys: DateSystem, by: PivotDateGroup, start: f64, end: f64) -> Vec
} }
} }
PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))), PivotDateGroup::Quarters => v.extend((1..=4).map(|q| format!("Qtr{q}"))),
PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::to_string)), PivotDateGroup::Months => v.extend(MONTHS.iter().map(|m| m.to_string())),
PivotDateGroup::Days | PivotDateGroup::None => { PivotDateGroup::Days | PivotDateGroup::None => {
const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
for (m, n) in MONTHS.iter().zip(DAYS) { for (m, n) in MONTHS.iter().zip(DAYS) {
+1 -1
View File
@@ -380,7 +380,7 @@ pub fn encode_xstring(s: &str) -> String {
} }
fn looks_like_escape(chars: &[char], i: usize) -> bool { fn looks_like_escape(chars: &[char], i: usize) -> bool {
chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') && (i + 2..i + 6).all(|k| chars.get(k).is_some_and(char::is_ascii_hexdigit)) chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') && (i + 2..i + 6).all(|k| chars.get(k).is_some_and(|c| c.is_ascii_hexdigit()))
} }
fn escape_unit(chars: &[char], i: usize) -> Option<u32> { fn escape_unit(chars: &[char], i: usize) -> Option<u32> {
+3 -1
View File
@@ -26,7 +26,9 @@ KEYCHAIN="$WORK/gridcraft-signing.keychain-db"
CERT="$WORK/gridcraft-signing.p12" CERT="$WORK/gridcraft-signing.p12"
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
trap 'rm -rf "$WORK"' EXIT # Remove only the decoded certificate: the keychain lives in the same directory and codesign
# needs it until the workflow's "Remove temporary keychain" step deletes it.
trap 'rm -f "${CERT:-}"' EXIT
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
security create-keychain -p "$KC_PASS" "$KEYCHAIN" security create-keychain -p "$KC_PASS" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN"
+1 -1
View File
@@ -20,7 +20,7 @@ const EXAMPLE_EXT: &[&str] = &["csv", "tsv", "json"];
const ASSET_DIRS: &[&str] = &["assets/", "docs/brand/", "docs/images/"]; const ASSET_DIRS: &[&str] = &["assets/", "docs/brand/", "docs/images/"];
fn ext(path: &str) -> String { fn ext(path: &str) -> String {
Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default() Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default()
} }
pub fn is_asset(path: &str) -> bool { pub fn is_asset(path: &str) -> bool {
+2 -2
View File
@@ -27,8 +27,8 @@ pub fn run(root: &Path) -> Result<(), String> {
}; };
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
let Some(stem) = path.file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_owned) else { continue }; let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") { if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue; continue;
} }
let Ok(source) = std::fs::read_to_string(&path) else { continue }; let Ok(source) = std::fs::read_to_string(&path) else { continue };
+1 -1
View File
@@ -174,7 +174,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
} }
} }
} }
out.sort_by_key(ToString::to_string); out.sort_by_key(|v| v.to_string());
out.dedup(); out.dedup();
out out
} }