Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 50ea1fe080 Share one font-list builder for the Thai and Hebrew faces
The two per-script functions repeated the same Windows, macOS, Linux and
variable-font setup. A single script_fonts helper now builds both lists,
keeping the candidate order unchanged, which clears the Sonar duplication gate.
2026-10-25 07:47:43 -04:00
Dylan M. Taylor 0e7456c0d3 Merge origin/main into fix/sonar-maintainability
Resolve theme.rs conflicts by keeping one per-field palette with main's light and dark values, so the Tokens constructors stay free of duplicated blocks.
2026-10-25 05:49:11 -04:00
Dylan M. Taylor 6401899ebc Reuse in_dirs for Thai and Hebrew Noto font candidates
Removes the remaining duplicated per-directory loops flagged by SonarCloud.
2026-10-24 22:59:13 -04:00
Dylan M. Taylor 5b2921e616 Share Linux Noto directory list across Thai and Hebrew font lookups
Removes the duplicated directory arrays flagged by SonarCloud new-code duplication.
2026-10-24 22:42:28 -04:00
Dylan M. Taylor 9f22a06627 Share the Noto CJK font list between the CJK script lists in theme.rs
The Japanese, Simplified and Korean lists each repeated the same loop over
the Linux CJK directories with a different locale file name. One helper
builds that loop, and each list keeps its own face index, so the lookup
order is unchanged.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-24 22:32:32 -04:00
Dylan M. Taylor 0ad5d7d6e0 Build the light and dark UI palettes from one table in theme.rs
Tokens::light() and Tokens::dark() listed the same 40 colour fields in the
same order, so every change had to be made twice. A single palette
constructor takes the mode and picks each colour's value, so each field is
written once and both modes keep their exact colours.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-24 22:24:02 -04:00
Dylan M. Taylor f77737b3ef Read the Windows fonts folder in one place in theme.rs
Every script-font list repeated the same WINDIR lookup. A single helper
returns the same path, so the duplicated lines go away without changing
which font files are tried.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-24 22:12:53 -04:00
Dylan M. Taylor a12e1e3296 Share the per-directory font path expansion in theme.rs
The candidate font lists repeated the same loop over the Linux font
directories in every role. A single helper expands them in the same order,
so the lookup order is unchanged and the duplicated blocks go away.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-24 22:03:39 -04:00
Dylan M. Taylor f3d84d4119 Apply rustfmt to the explicit-import cleanup so the fmt gate passes 2026-10-24 20:21:59 -04:00
Dylan M. Taylor b622394468 Fix clippy gate and a type error from the explicit-import change
Remove the unused imports the review flagged (cf.rs, pivot.rs, tables.rs, cmd/sheet.rs) so clippy -D warnings passes, and point the backstage parent-folder lookup at Path::to_str, which the fn-path rewrite had broken.

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-24 20:00:30 -04:00
Dylan M. Taylor fb69da0068 Fix engine imports 2026-10-24 08:52:30 +00:00
Dylan M. Taylor b8d4cd5161 Fix serde imports 2026-10-24 08:49:26 +00:00
Dylan M. Taylor 896b7f8e05 Fix imports 2026-10-24 08:45:15 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 4e86fe7ad3 Sonar: explicit imports, fn paths for closures, extract helpers for cognitive complexity
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:34:04 +00:00
47 changed files with 391 additions and 351 deletions
+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(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) } 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"))
&& !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(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default(); let msg = p.downcast_ref::<&str>().map(ToString::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(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase(); let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::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(|s| s.to_str()).unwrap_or("Book1").to_string(); let stem = path.file_stem().and_then(std::ffi::OsStr::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(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e)); 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 = 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(|poison| poison.into_inner())); let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::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(|poison| poison.into_inner()); let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::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(|a| a.labels()); let y2labels = y2.map(ValAxis::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(|s| s.to_string()).collect(), categories: cats.iter().map(ToString::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(|c| c.is_ascii_digit()) { while chars.peek().is_some_and(char::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(|v| v.to_text()).unwrap_or(Ok(String::new())), Value::Array(a) => a.data.first().map(Value::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(|v| v.to_bool()).unwrap_or(Ok(false)), Value::Array(a) => a.data.first().map(Value::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::*; use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
/// 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(|f| f.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(std::ffi::OsStr::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(|n| n.to_str()).unwrap_or("Book")) format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
} else { } else {
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string() std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::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(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::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<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new()); static TEST_COMMANDS: std::sync::Mutex<Vec<&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(|x| x.to_ascii_lowercase()) { match v.map(str::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(|c| c.is_ascii_digit())).take(3).collect(); let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::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));
+29 -17
View File
@@ -7,7 +7,10 @@ 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![
@@ -270,6 +273,29 @@ 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 };
@@ -279,13 +305,7 @@ 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)?;
// Refuse to push data off the sheet. check_push_off_sheet(s, sheet, axis, at, count, limit)?;
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;
@@ -297,15 +317,7 @@ 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").
if at > 0 { inherit_line_format(sh, axis, at, count);
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,8 +28,10 @@ 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::*; use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
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";
@@ -1705,6 +1707,26 @@ 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);
@@ -1768,29 +1790,7 @@ 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() {
let (formula, status, slack) = match (c.rel, &c.rhs) { rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
(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(|c| c.is_ascii_digit())?; let end = chars.iter().rposition(char::is_ascii_digit)?;
let mut start = end; let mut start = end;
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) { while start > 0 && chars.get(start - 1).is_some_and(char::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(|s| s.to_str()).unwrap_or("Sheet1"); let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::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(|n| n.to_str()).unwrap_or(path); let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::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(|n| n.to_str()).unwrap_or_default(); let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::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 => {
+5 -2
View File
@@ -601,8 +601,11 @@ impl Session {
let result = match result { let result = match result {
Ok(r) => r, Ok(r) => r,
Err(p) => { Err(p) => {
let msg = let msg = p
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into()); .downcast_ref::<&str>()
.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);
+4 -1
View File
@@ -11,7 +11,10 @@ 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::*; use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
/// (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(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default(); let msg = p.downcast_ref::<&str>().map(ToString::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::*; use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
/// 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::*; use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
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::*; use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes}; pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
+58 -46
View File
@@ -123,6 +123,60 @@ 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 {
@@ -133,31 +187,8 @@ 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 => { Operand::Blank => self.matches_blank(&v),
let blank = matches!(v, Value::Empty); Operand::Number(n) => self.matches_number(&v, *n),
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,
@@ -166,27 +197,8 @@ 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) => match self.op { Operand::Text(pat) => self.matches_text(&v, pat),
Op::Eq | Op::Ne => { Operand::CutText(kept) => self.matches_cut_text(&v, kept),
// 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, |x, y| x.div(y)) im_binary(a, Cx::div)
} }
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(|a, b| a.total_cmp(b)); xs.sort_by(f64::total_cmp);
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(|a, b| a.total_cmp(b)); sorted.sort_by(f64::total_cmp);
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<&'static str, usize>)> = OnceLock::new(); static REG: OnceLock<(Vec<FnSpec>, HashMap<&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(|a, b| a.total_cmp(b)); v.sort_by(f64::total_cmp);
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(|d| d.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(Vec::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(|d| d.len()).max(); let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::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(|d| d.is_empty()) { if delims.iter().all(Vec::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(|d| d.is_ascii_digit()) { if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) {
let mut j = i + 1; let mut j = i + 1;
while c.get(j).is_some_and(|d| d.is_ascii_digit()) { while c.get(j).is_some_and(char::is_ascii_digit) {
j += 1; j += 1;
} }
out.push_str("${"); out.push_str("${");
+9 -3
View File
@@ -7,7 +7,10 @@ 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};
@@ -622,8 +625,11 @@ 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 super::*; use crate::cell::CellPicture;
use serde::{Deserializer, Serializer}; use gridcraft_core::CellRef;
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(|c| c.to_string()).unwrap_or_default() dc.get(k).map(ToString::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(|n| n.to_str()).unwrap_or(path).to_string(); let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string();
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string(); let folder = p.parent().and_then(std::path::Path::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(|s| s.to_str()).unwrap_or("Book1").to_string(); let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::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(|n| n.to_str()).unwrap_or("This workbook").to_string(); let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::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(|name| name.to_str()).unwrap_or(""); let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::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));
} }
+8 -6
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(|c| c.to_lowercase()).collect(); let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::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(|c| c.to_lowercase()).collect(); let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
s.contains("italic") || s.contains("oblique") s.contains("italic") || s.contains("oblique")
} }
@@ -165,8 +165,10 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
stack.push(path); stack.push(path);
continue; continue;
} }
let is_font = let is_font = path
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc")); .extension()
.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;
} }
@@ -366,12 +368,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(|e| e.into_inner()) = keys.into_iter().collect(); *bound().write().unwrap_or_else(std::sync::PoisonError::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(|e| e.into_inner()).contains(role) bound().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(role)
} }
/// Serializes tests that mutate the process-wide bound-family registry. /// Serializes tests that mutate the process-wide bound-family registry.
+1 -2
View File
@@ -2200,9 +2200,8 @@ 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(|s| s.to_str()).unwrap_or("Book1").to_string(); let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::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.
+162 -190
View File
@@ -14,6 +14,11 @@ 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,
@@ -129,110 +134,70 @@ const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
impl Tokens { impl Tokens {
pub fn light() -> Tokens { pub fn light() -> Tokens {
Tokens { Tokens::palette(false)
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: true, dark,
window: Color32::from_rgb(0x29, 0x29, 0x29), window: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
ribbon: Color32::from_rgb(0x33, 0x33, 0x33), ribbon: pick(dark, Color32::from_rgb(0xFF, 0xFF, 0xFF), Color32::from_rgb(0x33, 0x33, 0x33)),
ribbon_border: Color32::from_rgb(0x40, 0x40, 0x40), ribbon_border: pick(dark, Color32::from_rgb(0xE3, 0xE3, 0xE3), Color32::from_rgb(0x40, 0x40, 0x40)),
text: Color32::from_rgb(0xEE, 0xEE, 0xEE), text: pick(dark, Color32::from_rgb(0x24, 0x24, 0x24), Color32::from_rgb(0xEE, 0xEE, 0xEE)),
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0), text_dim: pick(dark, Color32::from_rgb(0x61, 0x61, 0x61), Color32::from_rgb(0xB0, 0xB0, 0xB0)),
text_disabled: Color32::from_rgb(0x70, 0x70, 0x70), text_disabled: pick(dark, Color32::from_rgb(0xB0, 0xB0, 0xB0), Color32::from_rgb(0x70, 0x70, 0x70)),
accent: Color32::from_rgb(0x5C, 0xC5, 0x87), accent: pick(dark, Color32::from_rgb(0x10, 0x7C, 0x41), Color32::from_rgb(0x5C, 0xC5, 0x87)),
accent_dark: Color32::from_rgb(0x3F, 0xA8, 0x6C), accent_dark: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x3F, 0xA8, 0x6C)),
accent_soft: Color32::from_rgb(0x23, 0x45, 0x31), accent_soft: pick(dark, Color32::from_rgb(0xDF, 0xF1, 0xE6), Color32::from_rgb(0x23, 0x45, 0x31)),
hover: Color32::from_rgb(0x42, 0x42, 0x42), hover: pick(dark, Color32::from_rgb(0xEB, 0xEB, 0xEB), Color32::from_rgb(0x42, 0x42, 0x42)),
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D), pressed: pick(dark, Color32::from_rgb(0xDD, 0xDD, 0xDD), Color32::from_rgb(0x4D, 0x4D, 0x4D)),
separator: Color32::from_rgb(0x48, 0x48, 0x48), separator: pick(dark, Color32::from_rgb(0xE0, 0xE0, 0xE0), Color32::from_rgb(0x48, 0x48, 0x48)),
grid_bg: Color32::WHITE, grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0x3A, 0x3A, 0x3A), desk: pick(dark, Color32::from_rgb(0xDE, 0xDE, 0xDE), Color32::from_rgb(0x3A, 0x3A, 0x3A)),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1), gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0x30, 0x30, 0x30), header_bg: pick(dark, Color32::from_rgb(0xF8, 0xF8, 0xF8), Color32::from_rgb(0x30, 0x30, 0x30)),
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC), header_text: pick(dark, Color32::from_rgb(0x42, 0x42, 0x42), Color32::from_rgb(0xCC, 0xCC, 0xCC)),
header_line: Color32::from_rgb(0x45, 0x45, 0x45), header_line: pick(dark, Color32::from_rgb(0xD6, 0xD6, 0xD6), Color32::from_rgb(0x45, 0x45, 0x45)),
header_sel_bg: 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: Color32::from_rgb(0x6F, 0xD3, 0x9A), header_sel_text: pick(dark, Color32::from_rgb(0x0B, 0x5E, 0x31), Color32::from_rgb(0x6F, 0xD3, 0x9A)),
header_all_bg: Color32::from_rgb(0x2E, 0x55, 0x3E), header_all_bg: pick(dark, Color32::from_rgb(0xC8, 0xE6, 0xD3), Color32::from_rgb(0x2E, 0x55, 0x3E)),
sel_fill: Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x24), sel_fill: pick(dark, Color32::from_rgba_unmultiplied(0x10, 0x7C, 0x41, 0x1C), 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: Color32::from_rgb(0x1F, 0x1F, 0x1F), input_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x1F, 0x1F, 0x1F)),
input_border: Color32::from_rgb(0x50, 0x50, 0x50), input_border: pick(dark, Color32::from_rgb(0xD1, 0xD1, 0xD1), Color32::from_rgb(0x50, 0x50, 0x50)),
tab_bar: Color32::from_rgb(0x29, 0x29, 0x29), tab_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
tab_active: Color32::from_rgb(0x3A, 0x3A, 0x3A), tab_active: pick(dark, Color32::WHITE, Color32::from_rgb(0x3A, 0x3A, 0x3A)),
status_bar: Color32::from_rgb(0x29, 0x29, 0x29), status_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),
cell_text: Color32::BLACK, cell_text: Color32::BLACK,
menu_bg: Color32::from_rgb(0x30, 0x30, 0x30), menu_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x30, 0x30, 0x30)),
shadow: Color32::from_black_alpha(90), shadow: pick(dark, Color32::from_black_alpha(40), Color32::from_black_alpha(90)),
danger: Color32::from_rgb(0xFF, 0x95, 0x85), danger: pick(dark, Color32::from_rgb(0xC4, 0x2B, 0x1C), Color32::from_rgb(0xFF, 0x95, 0x85)),
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B), error_check_green: pick(dark, Color32::from_rgb(0x1E, 0x8E, 0x3E), 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: Color32::from_rgb(0x4A, 0x3F, 0x1E), notice_warn_bg: pick(dark, Color32::from_rgb(0xFF, 0xF4, 0xCE), Color32::from_rgb(0x4A, 0x3F, 0x1E)),
notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), notice_warn_border: pick(dark, Color32::from_rgb(0xF0, 0xC3, 0x6E), Color32::from_rgb(0x7A, 0x67, 0x30)),
notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), notice_info_bg: pick(dark, Color32::from_rgb(0xEA, 0xF2, 0xFB), Color32::from_rgb(0x24, 0x33, 0x44)),
notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), notice_info_border: pick(dark, Color32::from_rgb(0xB9, 0xD2, 0xEC), Color32::from_rgb(0x3B, 0x55, 0x72)),
on_accent: rgb(0x0F2A1A), on_accent: pick(dark, Color32::WHITE, rgb(0x0F2A1A)),
cat: [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)], cat: pick(
paper: rgb(0x454545), dark,
ref_colors: [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)], [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)],
),
sheet_ref_colors: SHEET_REF_COLORS, sheet_ref_colors: SHEET_REF_COLORS,
syntax: DARK_SYNTAX, syntax: pick(dark, LIGHT_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,
@@ -382,7 +347,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, &'static [u8]>> = std::sync::Mutex::new(std::collections::BTreeMap::new()); static FILES: std::sync::Mutex<std::collections::BTreeMap<String, &[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);
@@ -422,6 +387,12 @@ 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";
@@ -436,11 +407,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 d in lin { for p in in_dirs(
add(format!("{d}/cantarell/Cantarell-Regular.otf"), 0); &lin,
add(format!("{d}/noto/NotoSans-Regular.ttf"), 0); &["cantarell/Cantarell-Regular.otf", "noto/NotoSans-Regular.ttf", "dejavu/DejaVuSans.ttf", "liberation/LiberationSans-Regular.ttf"],
add(format!("{d}/dejavu/DejaVuSans.ttf"), 0); ) {
add(format!("{d}/liberation/LiberationSans-Regular.ttf"), 0); add(p, 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);
@@ -451,10 +422,8 @@ 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 d in lin { for p in in_dirs(&lin, &["noto/NotoSans-Bold.ttf", "dejavu/DejaVuSans-Bold.ttf", "liberation/LiberationSans-Bold.ttf"]) {
add(format!("{d}/noto/NotoSans-Bold.ttf"), 0); add(p, 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);
@@ -497,18 +466,16 @@ 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 d in lin { for p in in_dirs(&lin, &[&format!("crosextra/{carlito}"), &format!("carlito/{carlito}")]) {
add(format!("{d}/crosextra/{carlito}"), 0); add(p, 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 d in lin { for p in in_dirs(&lin, &[&format!("liberation/{lib}"), &format!("dejavu/{dejavu}")]) {
add(format!("{d}/liberation/{lib}"), 0); add(p, 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);
@@ -516,9 +483,8 @@ 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 d in lin { for p in in_dirs(&lin, &["liberation/LiberationSerif-Regular.ttf", "dejavu/DejaVuSerif.ttf"]) {
add(format!("{d}/liberation/LiberationSerif-Regular.ttf"), 0); add(p, 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);
@@ -527,14 +493,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 d in lin { for p in in_dirs(&lin, &["dejavu/DejaVuSansMono.ttf"]) {
add(format!("{d}/dejavu/DejaVuSansMono.ttf"), 0); add(p, 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 d in lin { for p in in_dirs(&lin, &["liberation/LiberationMono-Regular.ttf"]) {
add(format!("{d}/liberation/LiberationMono-Regular.ttf"), 0); add(p, 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);
@@ -725,6 +691,24 @@ 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,
@@ -733,15 +717,12 @@ const LINUX_CJK_DIRS: [&str; 22] = [
#[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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); let win = windows_fonts();
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
for d in LINUX_CJK_DIRS { v.extend(noto_cjk("jp", 0));
v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0));
v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face
}
v v
} }
@@ -750,17 +731,15 @@ 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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); let win = windows_fonts();
let mut v: Vec<(String, u32)> = Vec::new(); let mut v: Vec<(String, u32)> = vec![
v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (format!("{win}\\msyh.ttc"), 0), // Microsoft YaHei
v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun (format!("{win}\\simsun.ttc"), 0), // SimSun
v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei (format!("{win}\\simhei.ttf"), 0), // SimHei
v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS (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 (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.extend(noto_cjk("sc", 2));
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));
@@ -774,59 +753,65 @@ 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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); let win = windows_fonts();
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));
for d in LINUX_CJK_DIRS { v.extend(noto_cjk("kr", 1));
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);
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. /// Linux font directories searched by the Thai and Hebrew faces, in priority order.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn thai() -> Vec<(String, u32)> { fn linux_noto_dirs() -> [&'static str; 5] {
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
v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma /// Faces for one script: Windows files under `WINDIR\Fonts` first, then macOS paths, then the
v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif /// Noto/Linux files in every [`linux_noto_dirs`] directory, then the Fedora variable font.
v.push((format!("{mac_sup}/Thonburi.ttc"), 0)); #[cfg(not(target_arch = "wasm32"))]
v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0)); fn script_fonts(win: &[&str], mac: &[&str], lin_files: &[&str], variable: &str) -> Vec<(String, u32)> {
for d in lin { let w = windows_fonts();
v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0)); let mut v: Vec<(String, u32)> = win.iter().map(|f| (format!("{w}\\{f}"), 0)).collect();
v.push((format!("{d}/tlwg/Garuda.ttf"), 0)); v.extend(mac.iter().map(|p| (p.to_string(), 0)));
v.push((format!("{d}/garuda/Garuda.ttf"), 0)); v.extend(in_dirs(&linux_noto_dirs(), lin_files).into_iter().map(|p| (p, 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 = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); let win = windows_fonts();
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::new(); let mut v: Vec<(String, u32)> = vec![
v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI (format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI
v.push((format!("{win}\\dokchamp.ttf"), 0)); // DokChampa (format!("{win}\\dokchamp.ttf"), 0), // DokChampa
v.push((format!("{mac_sup}/Lao Sangam MN.ttf"), 0)); (format!("{mac_sup}/Lao Sangam MN.ttf"), 0),
v.push((format!("{mac_sup}/Lao MN.ttc"), 0)); (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));
@@ -839,33 +824,20 @@ 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)> {
let mac_sys = "/System/Library/Fonts"; script_fonts(
let mac_sup = "/System/Library/Fonts/Supplemental"; // David, FrankRuehl, Tahoma
let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); &["david.ttf", "frank.ttf", "tahoma.ttf"],
let lin = [ &[
"/usr/share/fonts/truetype/noto", "/System/Library/Fonts/ArialHB.ttc",
"/usr/share/fonts/opentype/noto", "/System/Library/Fonts/SFHebrew.ttf",
"/usr/share/fonts/truetype", "/System/Library/Fonts/Supplemental/Arial Hebrew.ttf",
"/usr/share/fonts", "/System/Library/Fonts/Supplemental/David.ttf",
"/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) "/System/Library/Fonts/LucidaGrande.ttc",
]; "/System/Library/Fonts/Lucida Grande.ttc",
let mut v: Vec<(String, u32)> = Vec::new(); ],
v.push((format!("{win}\\david.ttf"), 0)); // David &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"],
v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl "/usr/share/fonts/google-noto-vf/NotoSansHebrew[wght].ttf",
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
+35 -4
View File
@@ -1,7 +1,13 @@
//! 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 super::*; 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;
const MAX_POINTS: usize = 20_000; const MAX_POINTS: usize = 20_000;
const MAX_COORD: f64 = 1e7; const MAX_COORD: f64 = 1e7;
@@ -24,9 +30,7 @@ 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)
} }
/// Do not partially import a path: losing a curve, subpath, transform or line effect changes fn validate_shape_obj(obj: &El) -> Option<&El> {
/// 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"])
@@ -43,6 +47,10 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
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;
@@ -62,6 +70,10 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
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;
@@ -90,6 +102,10 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
{ {
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")
@@ -129,6 +145,10 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
} }
} }
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 {
@@ -148,6 +168,17 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
} }
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(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase(); let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::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(|m| m.to_string())), PivotDateGroup::Months => v.extend(MONTHS.iter().map(ToString::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(|c| c.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(char::is_ascii_hexdigit))
} }
fn escape_unit(chars: &[char], i: usize) -> Option<u32> { fn escape_unit(chars: &[char], i: usize) -> Option<u32> {
+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(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default() Path::new(path).extension().and_then(std::ffi::OsStr::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(|s| s.to_str()).map(str::to_owned) else { continue }; let Some(stem) = path.file_stem().and_then(std::ffi::OsStr::to_str).map(str::to_owned) else { continue };
if path.extension().and_then(|e| e.to_str()) != Some("rs") { if path.extension().and_then(std::ffi::OsStr::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(|v| v.to_string()); out.sort_by_key(ToString::to_string);
out.dedup(); out.dedup();
out out
} }