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]]
name = "gridcraft"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"arboard",
"chrono",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1540,7 +1540,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1551,7 +1551,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1563,7 +1563,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"chrono",
"js-sys",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1595,7 +1595,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -1604,7 +1604,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -1612,7 +1612,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -1621,7 +1621,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1633,18 +1633,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.11.1"
version = "0.12.1"
[[package]]
name = "gridcraft-ui-egui"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"craft-ui",
"egui",
@@ -1666,7 +1666,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"eframe",
"egui",
@@ -1683,7 +1683,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"aes",
"gridcraft-core",
@@ -4537,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.11.1"
version = "0.12.1"
dependencies = [
"gridcraft-engine",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.11.1"
version = "0.12.1"
edition = "2024"
license = "MIT OR Apache-2.0"
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) {
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)
{
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 {
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
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() }
})
}
+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());
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 mime = mime_for(name);
+2 -2
View File
@@ -180,11 +180,11 @@ fn services() -> Services {
})),
pick_save: Some(Box::new(|suggested: &str| {
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.
const TYPES: [(&str, &str); 4] =
[("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 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)) {
+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());
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 {
if worker.join().is_err() {
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()));
});
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.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);
if !horizontal {
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) {
(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),
+1 -1
View File
@@ -77,7 +77,7 @@ fn sample(kind: ChartKind) -> ChartData {
ChartData {
category_levels: vec![],
palette: vec![],
categories: cats.iter().map(ToString::to_string).collect(),
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
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) {
chars.next();
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();
}
}
+2 -2
View File
@@ -245,7 +245,7 @@ impl Value {
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
Value::Text(t) => Ok(t.to_string()),
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.
@@ -264,7 +264,7 @@ impl Value {
}
}
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).
+1 -1
View File
@@ -4,7 +4,7 @@
use std::collections::HashMap;
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.
#[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()));
}
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 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!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
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 {
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
// 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
// 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}));
}
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()
}
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
/// 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> {
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 == "columns" || a == "column" || a == "cols" => Ok("columns"),
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;
}
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;
if let Ok(v) = digits.parse::<f32>() {
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 serde_json::{Value as Json, json};
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,
};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
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> {
let (at, count) = lines(s, p, axis)?;
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)?;
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 name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
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);
// 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 });
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 serde_json::{Value as Json, json};
use super::check_arrays;
use super::whatif::Trial;
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
use gridcraft_core::split_sheet;
use super::*;
const CMD: &str = "data.solver";
const REPORT_CMD: &str = "data.solverReport";
@@ -1707,26 +1705,6 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
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 {
let m = inp.model;
let mut trial = Trial::new(d);
@@ -1790,7 +1768,29 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
Rhs::None => vec![],
};
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);
+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).
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
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;
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;
}
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).
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 = clean.trim_matches('\'').to_string();
// "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:
// a typo like `.cvs` would otherwise produce a file no other app recognises.
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.")));
};
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 VBA project, so any other name saves without it, as Excel does.
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()))
}
FileKind::Xls => {
+2 -5
View File
@@ -601,11 +601,8 @@ impl Session {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg = p
.downcast_ref::<&str>()
.map(ToString::to_string)
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
let msg =
p.downcast_ref::<&str>().map(|s| s.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
// command opened.
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;
pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
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 gridcraft_model::*;
use crate::pivot_calc::CalcSet;
+1 -1
View File
@@ -2,7 +2,7 @@
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
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).
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(Err(e)) => Err(fail(step, kind, e)),
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}")))
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
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!`.
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::{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};
#[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 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};
+46 -58
View File
@@ -123,60 +123,6 @@ impl Criterion {
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.
pub(crate) fn matches(&self, v: &Value) -> bool {
let v = match v {
@@ -187,8 +133,31 @@ impl Criterion {
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
}
match &self.operand {
Operand::Blank => self.matches_blank(&v),
Operand::Number(n) => self.matches_number(&v, *n),
Operand::Blank => {
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 {
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
_ => self.op == Op::Ne,
@@ -197,8 +166,27 @@ impl Criterion {
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
_ => self.op == Op::Ne,
},
Operand::Text(pat) => self.matches_text(&v, pat),
Operand::CutText(kept) => self.matches_cut_text(&v, kept),
Operand::Text(pat) => 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,
},
},
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'))
}
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> {
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::Min => xs.iter().copied().reduce(f64::min),
Aggregate::Median => {
xs.sort_by(f64::total_cmp);
xs.sort_by(|a, b| a.total_cmp(b));
let k = xs.len() / 2;
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) }
@@ -415,7 +415,7 @@ impl Timeline {
return None;
}
let mut sorted = times.to_vec();
sorted.sort_by(f64::total_cmp);
sorted.sort_by(|a, b| a.total_cmp(b));
sorted.dedup();
let axis = detect_axis(&sorted, sys).ok()?;
let last = axis.position(*sorted.last()?)?.round();
+1 -1
View File
@@ -201,7 +201,7 @@ impl FnSpec {
pub(crate) const VAR: usize = usize::MAX;
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(|| {
let mut v: Vec<FnSpec> = Vec::new();
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> {
v.sort_by(f64::total_cmp);
v.sort_by(|a, b| a.total_cmp(b));
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();
while j > 0 {
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 {
Some(l) => {
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;
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 {
Some(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>> {
if delims.iter().all(Vec::is_empty) {
if delims.iter().all(|d| d.is_empty()) {
return vec![s.to_vec()];
}
let mut out = Vec::new();
@@ -748,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
let mut i = 0;
while i < c.len() {
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;
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;
}
out.push_str("${");
+3 -9
View File
@@ -7,10 +7,7 @@ use gridcraft_core::{CellRef, RangeRef, Value};
use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
Validation,
};
use crate::features::*;
use crate::store::CellStore;
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.
mod picture_map {
use crate::cell::CellPicture;
use gridcraft_core::CellRef;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::collections::BTreeMap;
use std::sync::Arc;
use super::*;
use serde::{Deserializer, Serializer};
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
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 {
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
} 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() {
match toks.get(ti) {
+2 -2
View File
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
'ž' => 'z',
'Œ' => 'W',
'œ' => 'm',
'‘'..='‚' => '\'',
'“'..='„' => '"',
'‘' | '’' | '‚' => '\'',
'“' | '”' | '„' => '"',
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
'\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.
fn split_path(path: &str) -> (String, String) {
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 folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string();
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
(name, folder)
}
@@ -888,7 +888,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
_ => return false,
};
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 {
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
// 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`
// 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 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"
// rather than asking for the password again.
let mut values = params.clone();
@@ -1369,7 +1369,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
&& let Some(path) = pick()
{
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("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).
#[cfg(not(target_arch = "wasm32"))]
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)] = &[
("hairline", 100.0),
("thin", 100.0),
@@ -107,7 +107,7 @@ impl Face {
/// Whether a style name reads as italic or oblique.
#[cfg(not(target_arch = "wasm32"))]
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")
}
@@ -165,10 +165,8 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
stack.push(path);
continue;
}
let is_font = path
.extension()
.and_then(std::ffi::OsStr::to_str)
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
let is_font =
path.extension().and_then(|e| e.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) {
continue;
}
@@ -368,12 +366,12 @@ fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
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?
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.
+2 -1
View File
@@ -2200,8 +2200,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
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
// under it; a right press that wobbles into a drag counts too (#2067).
+1 -1
View File
@@ -567,7 +567,7 @@ impl SheetApp {
return;
}
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(path) = pick(&format!("{stem}.pdf")) else { return };
// 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};
/// 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)]
pub struct Tokens {
pub dark: bool,
@@ -134,70 +129,110 @@ const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
impl 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 {
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 {
dark,
window: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
ribbon: pick(dark, Color32::from_rgb(0xFF, 0xFF, 0xFF), Color32::from_rgb(0x33, 0x33, 0x33)),
ribbon_border: pick(dark, Color32::from_rgb(0xE3, 0xE3, 0xE3), Color32::from_rgb(0x40, 0x40, 0x40)),
text: pick(dark, Color32::from_rgb(0x24, 0x24, 0x24), Color32::from_rgb(0xEE, 0xEE, 0xEE)),
text_dim: pick(dark, Color32::from_rgb(0x61, 0x61, 0x61), Color32::from_rgb(0xB0, 0xB0, 0xB0)),
text_disabled: pick(dark, Color32::from_rgb(0xB0, 0xB0, 0xB0), Color32::from_rgb(0x70, 0x70, 0x70)),
accent: pick(dark, Color32::from_rgb(0x10, 0x7C, 0x41), Color32::from_rgb(0x5C, 0xC5, 0x87)),
accent_dark: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x3F, 0xA8, 0x6C)),
accent_soft: pick(dark, Color32::from_rgb(0xDF, 0xF1, 0xE6), Color32::from_rgb(0x23, 0x45, 0x31)),
hover: pick(dark, Color32::from_rgb(0xEB, 0xEB, 0xEB), Color32::from_rgb(0x42, 0x42, 0x42)),
pressed: pick(dark, Color32::from_rgb(0xDD, 0xDD, 0xDD), Color32::from_rgb(0x4D, 0x4D, 0x4D)),
separator: pick(dark, Color32::from_rgb(0xE0, 0xE0, 0xE0), Color32::from_rgb(0x48, 0x48, 0x48)),
dark: true,
window: Color32::from_rgb(0x29, 0x29, 0x29),
ribbon: Color32::from_rgb(0x33, 0x33, 0x33),
ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40),
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
text_disabled: Color32::from_rgb(0x70, 0x70, 0x70),
accent: Color32::from_rgb(0x5C, 0xC5, 0x87),
accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C),
accent_soft: Color32::from_rgb(0x23, 0x45, 0x31),
hover: Color32::from_rgb(0x42, 0x42, 0x42),
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
separator: Color32::from_rgb(0x48, 0x48, 0x48),
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),
header_bg: pick(dark, Color32::from_rgb(0xF8, 0xF8, 0xF8), Color32::from_rgb(0x30, 0x30, 0x30)),
header_text: pick(dark, Color32::from_rgb(0x42, 0x42, 0x42), Color32::from_rgb(0xCC, 0xCC, 0xCC)),
header_line: pick(dark, Color32::from_rgb(0xD6, 0xD6, 0xD6), 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_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), 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)),
header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
header_line: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_bg: Color32::from_rgb(0x45, 0x45, 0x45),
header_sel_text: Color32::from_rgb(0x6F, 0xD3, 0x9A),
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E),
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24),
sel_border: Color32::from_rgb(0x10, 0x7C, 0x41),
input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
tab_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
tab_active: pick(dark, Color32::WHITE, Color32::from_rgb(0x3A, 0x3A, 0x3A)),
status_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
input_bg: Color32::from_rgb(0x1F, 0x1F, 0x1F),
input_border: Color32::from_rgb(0x50, 0x50, 0x50),
tab_bar: Color32::from_rgb(0x29, 0x29, 0x29),
tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A),
status_bar: Color32::from_rgb(0x29, 0x29, 0x29),
cell_text: Color32::BLACK,
menu_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x30, 0x30, 0x30)),
shadow: pick(dark, Color32::from_black_alpha(40), Color32::from_black_alpha(90)),
danger: pick(dark, Color32::from_rgb(0xC4, 0x2B, 0x1C), Color32::from_rgb(0xFF, 0x95, 0x85)),
error_check_green: pick(dark, Color32::from_rgb(0x1E, 0x8E, 0x3E), Color32::from_rgb(0x4C, 0xC2, 0x6B)),
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: Color32::from_black_alpha(90),
danger: Color32::from_rgb(0xFF, 0x95, 0x85),
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK,
notice_warn_bg: pick(dark, Color32::from_rgb(0xFF, 0xF4, 0xCE), Color32::from_rgb(0x4A, 0x3F, 0x1E)),
notice_warn_border: pick(dark, Color32::from_rgb(0xF0, 0xC3, 0x6E), 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_border: pick(dark, Color32::from_rgb(0xB9, 0xD2, 0xEC), Color32::from_rgb(0x3B, 0x55, 0x72)),
on_accent: pick(dark, Color32::WHITE, rgb(0x0F2A1A)),
cat: pick(
dark,
[rgb(0x2F6FCF), rgb(0x1E8A4C), rgb(0xC8601A), rgb(0xC8372D), rgb(0x8350C8), rgb(0x13808A), rgb(0x9C6C00), rgb(0x5A6B7D)],
[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)],
),
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),
on_accent: rgb(0x0F2A1A),
cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],
paper: rgb(0x454545),
ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],
sheet_ref_colors: SHEET_REF_COLORS,
syntax: pick(dark, LIGHT_SYNTAX, DARK_SYNTAX),
syntax: DARK_SYNTAX,
warning: rgb(0xF2C32E),
on_warning: rgb(0x1F1F1F),
sheet_control_bg: SHEET_CONTROL_BG,
@@ -347,7 +382,7 @@ fn try_load(paths: &[(&str, u32)]) -> Option<FontData> {
/// looked for again next time.
#[cfg(not(target_arch = "wasm32"))]
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);
if let Some(&bytes) = files.get(path) {
return font_parses(bytes, index).then_some(bytes);
@@ -387,12 +422,6 @@ fn home_fonts(file: &str) -> String {
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.
fn candidates(role: &str) -> Vec<(String, u32)> {
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}/HelveticaNeue.ttc"), 0);
add(format!("{win}\\segoeui.ttf"), 0);
for p in in_dirs(
&lin,
&["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"],
) {
add(p, 0);
for d in lin {
add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0);
add(format!("{d}/noto/NotoSans-Regular.ttf"), 0);
add(format!("{d}/dejavu/DejaVuSans.ttf"), 0);
add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0);
}
// Fedora keeps each family in its own package folder (#54).
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" => {
add(format!("{mac_sys}/HelveticaNeue.ttc"), 1);
add(format!("{win}\\segoeuib.ttf"), 0);
for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) {
add(p, 0);
for d in lin {
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/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(home_fonts(calibri_mac), 0);
add(format!("{win}\\{calibri_win}"), 0);
for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) {
add(p, 0);
for d in lin {
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).
add(format!("/usr/share/fonts/google-carlito-fonts/{carlito}"), 0);
add(home_fonts(carlito), 0);
add(format!("{mac_sup}/{arial_mac}"), 0);
add(format!("{win}\\{arial_win}"), 0);
for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) {
add(p, 0);
for d in lin {
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/dejavu-sans-fonts/{dejavu}"), 0);
@@ -483,8 +516,9 @@ fn candidates(role: &str) -> Vec<(String, u32)> {
"serif" => {
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
add(format!("{win}\\times.ttf"), 0);
for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) {
add(p, 0);
for d in lin {
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/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}/Menlo.ttc"), 0);
add(format!("{win}\\consola.ttf"), 0);
for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) {
add(p, 0);
for d in lin {
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 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
// (in Workstation and KDE by default), Noto Sans Mono (variable, Regular by default) always.
for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) {
add(p, 0);
for d in lin {
add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 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);
@@ -691,24 +725,6 @@ const LINUX_CJK_DIRS: [&str; 22] = [
"/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).
///
/// 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"))]
fn cjk_han_japanese() -> Vec<(String, u32)> {
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();
v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic
v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic
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
}
@@ -731,15 +750,17 @@ fn cjk_han_japanese() -> Vec<(String, u32)> {
#[cfg(not(target_arch = "wasm32"))]
fn cjk_han_simplified() -> Vec<(String, u32)> {
let mac = "/System/Library/Fonts";
let win = windows_fonts();
let mut v: Vec<(String, u32)> = vec![
(format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei
(format!("{win}\\simsun.ttc"), 0), // SimSun
(format!("{win}\\simhei.ttf"), 0), // SimHei
(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.extend(noto_cjk("sc", 2));
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();
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS
v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face
}
for d in LINUX_CJK_DIRS {
v.push((format!("{d}/wqy/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"))]
fn cjk_hangul() -> Vec<(String, u32)> {
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();
v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic
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
}
/// 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"))]
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/opentype/noto",
"/usr/share/fonts/truetype",
"/usr/share/fonts",
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
]
}
/// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the
/// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font.
#[cfg(not(target_arch = "wasm32"))]
fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> {
let w = windows_fonts();
let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect();
v.extend(mac.iter().map(|p| (p.to_string(), 0)));
v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 0)));
v.push((variable.into(), 0));
];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif
v.push((format!("{mac_sup}/Thonburi.ttc"), 0));
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0));
v.push((format!("{d}/tlwg/Garuda.ttf"), 0));
v.push((format!("{d}/garuda/Garuda.ttf"), 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
}
/// 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
/// MN and Lao MN; Linux usually has Noto Sans Lao or Phetsarath OT.
#[cfg(not(target_arch = "wasm32"))]
fn lao() -> Vec<(String, u32)> {
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 mut v: Vec<(String, u32)> = vec![
(format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI
(format!("{win}\\dokchamp.ttf"), 0), // DokChampa
(format!("{mac_sup}/Lao Sangam MN.ttf"), 0),
(format!("{mac_sup}/Lao MN.ttc"), 0),
];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI
v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa
v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0));
v.push((format!("{mac_sup}/Lao MN.ttc"), 0));
for d in lin {
v.push((format!("{d}/NotoSansLao-Regular.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.
#[cfg(not(target_arch = "wasm32"))]
fn hebrew() -> Vec<(String, u32)> {
script_fonts(
// David, FrankRuehl, Tahoma
&["david.ttf", "frank.ttf", "tahoma.ttf"],
&[
"/System/Library/Fonts/ArialHB.ttc",
"/System/Library/Fonts/SFHebrew.ttf",
"/System/Library/Fonts/Supplemental/Arial Hebrew.ttf",
"/System/Library/Fonts/Supplemental/David.ttf",
"/System/Library/Fonts/LucidaGrande.ttc",
"/System/Library/Fonts/Lucida Grande.ttc",
],
&["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"],
"/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf",
)
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/opentype/noto",
"/usr/share/fonts/truetype",
"/usr/share/fonts",
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762)
];
let mut v: Vec<(String, u32)> = Vec::new();
v.push((format!("{win}\\david.ttf"), 0)); // David
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
+4 -35
View File
@@ -1,13 +1,7 @@
//! 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.
use std::fmt::Write as _;
use gridcraft_model::{Anchor, Color, Shape, ShapeKind, Theme};
use super::xfrm;
use crate::tables::EMU_PER_PX;
use crate::xml::El;
use super::*;
const MAX_POINTS: usize = 20_000;
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)
}
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"
|| !only_children(obj, &["nvSpPr", "spPr"])
|| !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() {
return None;
}
Some(sp)
}
fn validate_xfrm(sp: &El, anchor: &Anchor) -> Option<()> {
let xf = one(sp, "xfrm")?;
if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) {
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) {
return None;
}
Some(())
}
fn validate_cust_geom(sp: &El) -> Option<(&El, i64, i64)> {
let geom = one(sp, "custGeom")?;
if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) {
return None;
@@ -102,10 +90,6 @@ fn validate_cust_geom(sp: &El) -> Option<(&El, i64, i64)> {
{
return None;
}
Some((path, w, h))
}
fn validate_line(sp: &El) -> Option<(i64, Color)> {
let ln = one(sp, "ln")?;
if !only_attrs(ln, &["w", "cap", "cmpd", "algn"])
|| 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")?)?;
Some((width, line))
}
fn parse_points(path: &El, anchor: &Anchor, w: i64, h: i64) -> Option<String> {
let mut text = String::new();
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 {
@@ -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);
}
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() })
}
+1 -1
View File
@@ -110,7 +110,7 @@ pub enum XlsxKind {
impl XlsxKind {
/// The kind a file name's extension asks for; anything else is a plain workbook.
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() {
"xlsm" => XlsxKind::MacroWorkbook,
"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::Months => v.extend(MONTHS.iter().map(ToString::to_string)),
PivotDateGroup::Months => v.extend(MONTHS.iter().map(|m| m.to_string())),
PivotDateGroup::Days | PivotDateGroup::None => {
const DAYS: [u32; 12] = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
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 {
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> {
+3 -1
View File
@@ -26,7 +26,9 @@ KEYCHAIN="$WORK/gridcraft-signing.keychain-db"
CERT="$WORK/gridcraft-signing.p12"
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"
security create-keychain -p "$KC_PASS" "$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/"];
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 {
+2 -2
View File
@@ -27,8 +27,8 @@ pub fn run(root: &Path) -> Result<(), String> {
};
for entry in entries.flatten() {
let path = entry.path();
let Some(stem) = path.file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_owned) else { continue };
if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
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
}