Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 97c169b56a M7: verify Agile encryption's dataIntegrity before returning plaintext
Agile decryption checked only the password verifier, which authenticates
the key — not the package. An attacker who could replace EncryptedPackage
ciphertext could change workbook content undetected ([MS-OFFCRYPTO]
2.3.4.14, the descriptor's dataIntegrity element).

The reader now requires the dataIntegrity pair, decrypts it with the
recovered package key, and checks HMAC(hash, hmacKey) over the whole
EncryptedPackage stream (StreamSize field included) against the stored
value before any plaintext is returned. Excel always writes the element
("SHOULD" in the spec); descriptors without it are refused as damaged
rather than accepted unauthenticated.

- crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key
  longer than the block size
- fixtures write the pair like Excel; new tests: missing/empty/ragged
  integrity fields, tampered ciphertext and descriptor, a changed
  StreamSize field
2026-10-24 06:32:45 -04:00
51 changed files with 484 additions and 393 deletions
+2 -2
View File
@@ -304,7 +304,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
if !walk(&p, depth + 1, out) {
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;
@@ -319,7 +319,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
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
@@ -350,7 +350,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
@@ -76,7 +76,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
@@ -244,7 +244,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
@@ -599,7 +599,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
@@ -388,9 +388,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
@@ -572,11 +572,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};
@@ -616,11 +613,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();
@@ -1336,7 +1336,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
@@ -2184,8 +2184,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
@@ -566,7 +566,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.
+170 -149
View File
@@ -9,11 +9,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(dark: bool, light: Color32, dark_value: Color32) -> Color32 {
if dark { dark_value } else { light }
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tokens {
pub dark: bool,
@@ -63,56 +58,92 @@ pub struct Tokens {
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(0x10, 0x7C, 0x41),
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),
}
}
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)),
grid_bg: pick(dark, Color32::WHITE, Color32::WHITE),
desk: pick(dark, Color32::from_rgb(0xDE, 0xDE, 0xDE), Color32::from_rgb(0x3A, 0x3A, 0x3A)),
gridline: pick(dark, Color32::from_rgb(0xE1, 0xE1, 0xE1), 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(0x10, 0x7C, 0x41), Color32::from_rgb(0x5C, 0xC5, 0x87)),
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)),
sel_border: pick(dark, Color32::from_rgb(0x10, 0x7C, 0x41), 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)),
cell_text: pick(dark, Color32::BLACK, 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(0xF1, 0x70, 0x5F)),
error_check_green: pick(dark, Color32::from_rgb(0x1E, 0x8E, 0x3E), Color32::from_rgb(0x4C, 0xC2, 0x6B)),
warning_fill: pick(dark, Color32::from_rgb(0xF2, 0xB7, 0x05), Color32::from_rgb(0xF2, 0xB7, 0x05)),
warning_ink: pick(dark, Color32::BLACK, 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)),
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: Color32::from_rgb(0x3A, 0x3A, 0x3A),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
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(0x5C, 0xC5, 0x87),
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: 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: Color32::from_rgb(0x30, 0x30, 0x30),
shadow: Color32::from_black_alpha(90),
danger: Color32::from_rgb(0xF1, 0x70, 0x5F),
error_check_green: Color32::from_rgb(0x4C, 0xC2, 0x6B),
warning_fill: Color32::from_rgb(0xF2, 0xB7, 0x05),
warning_ink: Color32::BLACK,
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),
}
}
@@ -230,7 +261,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);
@@ -270,12 +301,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";
@@ -290,11 +315,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);
@@ -305,8 +330,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);
@@ -349,16 +376,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);
@@ -366,8 +395,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);
@@ -376,14 +406,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);
@@ -574,24 +604,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,
@@ -600,12 +612,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
}
@@ -614,15 +629,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));
@@ -636,43 +653,41 @@ 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.
#[cfg(not(target_arch = "wasm32"))]
fn linux_noto_dirs() -> [&'static str; 5] {
[
"/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)
]
}
/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda.
#[cfg(not(target_arch = "wasm32"))]
fn thai() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = windows_fonts();
let lin = linux_noto_dirs();
let mut v: Vec<(String, u32)> = vec![
(format!("{win}\\LeelawUI.ttf"), 0), // Leelawadee UI
(format!("{win}\\tahoma.ttf"), 0), // Tahoma
(format!("{win}\\micross.ttf"), 0), // Microsoft Sans Serif
(format!("{mac_sup}/Thonburi.ttc"), 0),
(format!("{mac_sys}/SukhumvitSet.ttc"), 0),
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)
];
for p in in_dirs(&lin, &["NotoSansThai-Regular.ttf", "tlwg/Garuda.ttf", "garuda/Garuda.ttf"]) {
v.push((p, 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));
@@ -684,14 +699,13 @@ fn thai() -> Vec<(String, u32)> {
#[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));
@@ -706,21 +720,28 @@ fn lao() -> Vec<(String, u32)> {
fn hebrew() -> Vec<(String, u32)> {
let mac_sys = "/System/Library/Fonts";
let mac_sup = "/System/Library/Fonts/Supplemental";
let win = windows_fonts();
let lin = linux_noto_dirs();
let mut v: Vec<(String, u32)> = vec![
(format!("{win}\\david.ttf"), 0), // David
(format!("{win}\\frank.ttf"), 0), // FrankRuehl
(format!("{win}\\tahoma.ttf"), 0), // Tahoma
(format!("{mac_sys}/ArialHB.ttc"), 0), // Arial Hebrew
(format!("{mac_sys}/SFHebrew.ttf"), 0),
(format!("{mac_sup}/Arial Hebrew.ttf"), 0),
(format!("{mac_sup}/David.ttf"), 0),
(format!("{mac_sys}/LucidaGrande.ttc"), 0),
(format!("{mac_sys}/Lucida Grande.ttc"), 0),
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)
];
for p in in_dirs(&lin, &["NotoSansHebrew-Regular.ttf", "dejavu/DejaVuSans.ttf", "freefont/FreeSans.ttf"]) {
v.push((p, 0));
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
+55 -3
View File
@@ -26,6 +26,9 @@ const BLOCK: usize = 16;
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field.
pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6];
pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33];
/// The password key encryptor's URI (the other kind is certificate-based).
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
@@ -165,6 +168,14 @@ impl Hash {
Hash::Sha512 => run::<sha2::Sha512>(parts),
}
}
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
fn block_size(self) -> usize {
match self {
Hash::Sha384 | Hash::Sha512 => 128,
Hash::Sha1 | Hash::Sha256 => 64,
}
}
}
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
@@ -184,6 +195,19 @@ pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
k
}
/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses.
pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec<u8> {
let bs = hash.block_size();
let mut k = key.to_vec();
if k.len() > bs {
k = hash.digest(&[&k]);
}
k.resize(bs, 0);
let pad = |x: u8| k.iter().map(|b| b ^ x).collect::<Vec<u8>>();
let inner = hash.digest(&[&pad(0x36), message]);
hash.digest(&[&pad(0x5C), &inner])
}
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
@@ -368,8 +392,9 @@ impl Params {
}
}
/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its
/// three encrypted values (verifier input, verifier hash, package key).
/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its
/// three encrypted values (verifier input, verifier hash, package key), and the package's
/// integrity pair, which Excel always writes and the reader requires.
struct AgileDescriptor {
key_data: Params,
key: Params,
@@ -377,6 +402,8 @@ struct AgileDescriptor {
verifier_input: Vec<u8>,
verifier_hash: Vec<u8>,
key_value: Vec<u8>,
hmac_key: Vec<u8>,
hmac_value: Vec<u8>,
}
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
@@ -395,6 +422,16 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
}
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
// A descriptor without dataIntegrity can't prove its ciphertext is the one the password
// protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14).
let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?;
let hmac_field = |attr: &str| {
let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?;
if v.is_empty() || v.len() % BLOCK != 0 {
return Err(malformed(format!("a whole-block {attr}")));
}
Ok(v)
};
Ok(AgileDescriptor {
key_data,
key,
@@ -402,11 +439,14 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
verifier_input: field("encryptedVerifierHashInput")?,
verifier_hash: field("encryptedVerifierHashValue")?,
key_value: field("encryptedKeyValue")?,
hmac_key: hmac_field("encryptedHmacKey")?,
hmac_value: hmac_field("encryptedHmacValue")?,
})
}
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?;
let descriptor = agile_descriptor(xml)?;
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
let h = iterated_hash(key.hash, password, &key.salt, spin);
let iv = key.iv(None);
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
@@ -423,6 +463,18 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptE
}
let secret = field(&key_value, &BLOCK_KEY)?;
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
// Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the
// package key (the verifier above only authenticates the password), keyed by a random
// salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included.
let mut mac_field = hmac_key;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
mac_field.truncate(key_data.salt.len());
let mut declared = hmac_value;
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
let mac = hmac(key_data.hash, &mac_field, package);
if declared.get(..mac.len()) != Some(mac.as_slice()) {
return Err(malformed("the decrypted data failed its integrity check"));
}
let (size, data) = package_parts(package)?;
let mut out = Vec::with_capacity(size);
for (i, segment) in data.chunks(SEGMENT).enumerate() {
+79 -3
View File
@@ -6,6 +6,9 @@ use super::*;
use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header};
const PASSWORD: &str = "Pass1234";
/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values
/// can't be reached anyway (scheme errors, descriptor-parse damage).
const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
@@ -107,9 +110,9 @@ fn unsupported_schemes_are_named() {
assert_eq!(unsupported(&rc4), "RC4");
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate");
let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate");
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
}
@@ -136,7 +139,7 @@ fn damaged_files_are_malformed_not_panics() {
ragged[..8].copy_from_slice(&len.to_le_bytes());
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
}
let xml = agile_info(PASSWORD, 1000);
let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB);
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
@@ -155,3 +158,76 @@ fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
);
}
#[test]
fn hmac_matches_the_rfc2104_test_vectors() {
use crate::crypto::hmac as mac;
let h = |v: Vec<u8>| v.iter().map(|b| format!("{b:02x}")).collect::<String>();
// RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors.
assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00");
assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
// RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first.
assert_eq!(
h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
);
}
/// Strips the `<dataIntegrity …/>` element from an Agile descriptor.
fn without_integrity(info: &[u8]) -> Vec<u8> {
let xml = std::str::from_utf8(&info[8..]).unwrap().to_string();
let start = xml.find("<dataIntegrity").unwrap();
let end = start + xml[start..].find("/>").unwrap() + 2;
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
with_agile_header(&stripped)
}
#[test]
fn integrity_is_required_and_enforced() {
let plain = payload(300);
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
// An attacker can't just delete the integrity element to get the package verified.
assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD),
Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity")));
// An empty or ragged field is damage, not a pass.
for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] {
let xml =
agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1));
let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD);
assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1);
}
}
#[test]
fn modified_ciphertext_fails_the_integrity_check() {
let plain = payload(300);
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
// The correct password proves the verifier can't vouch for a modified package.
for at in [8usize, 100, package.len() - 5] {
let mut tampered = package.clone();
tampered[at] ^= 0x80;
assert!(
matches!(decrypt_streams(&info, &tampered, PASSWORD),
Err(DecryptError::Malformed(m)) if m.contains("integrity")),
"byte {at}"
);
}
// And neither can the descriptor: a swapped integrity value is refused.
let xml = String::from_utf8_lossy(&info[8..]).to_string();
let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len();
let mut xml: Vec<u8> = xml.into_bytes();
xml[at] ^= b'A' ^ b'B';
assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_))));
}
#[test]
fn integrity_matches_the_stream_not_the_plaintext() {
// The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the
// stated size alone must fail, while the actual bytes round-trip.
let plain = payload(4200);
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
let mut ours = package.clone();
ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes());
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
}
+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) {
+17 -8
View File
@@ -9,8 +9,8 @@
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use crate::crypto::{
BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash,
standard_key,
BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params,
SEGMENT, derive, hmac, iterated_hash, standard_key,
};
/// The spin count `encrypt_workbook` uses: Excel's default.
@@ -21,6 +21,7 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
const SECRET_KEY: [u8; 32] =
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127];
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
/// (AES-256, SHA-512) inside a compound file.
@@ -202,8 +203,9 @@ fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
p
}
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16.
pub(crate) fn agile_info(password: &str, spin: u32) -> String {
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the
/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries.
pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String {
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
let iv = key.iv(None);
@@ -217,9 +219,10 @@ pub(crate) fn agile_info(password: &str, spin: u32) -> String {
base64_encode(salt)
)
};
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
params(&KEY_DATA_SALT),
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
params(KEY_DATA_SALT.as_slice()),
params(&PASSWORD_SALT),
)
}
@@ -231,14 +234,20 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
info
}
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512).
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
let mut body = vec![];
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
}
(with_agile_header(&agile_info(password, spin)), package(plain.len(), body))
let stream = package(plain.len(), body);
let (hmac_key_b64, hmac_value_b64) = (
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)),
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))),
);
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
}
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
+2 -1
View File
@@ -56,7 +56,8 @@ fn schemes_no_password_opens_are_named_before_asking_for_one() {
let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4"));
assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible"));
let cert = agile_info("pw", 1000).replace("keyEncryptor/password", "keyEncryptor/certificate");
let cert =
agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate");
assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate"));
assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_))));
// Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo.
+1 -1
View File
@@ -360,7 +360,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> {
+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
}