diff --git a/Cargo.lock b/Cargo.lock index 6d618a02a..17ebf848c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.7.0" +version = "0.8.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.7.0" +version = "0.8.0" [[package]] name = "gridcraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.7.0" +version = "0.8.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 98dd572e0..a967b999f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index e9e05a2e6..4c57ccf41 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -1,7 +1,11 @@ //! Localhost JSON-lines control server. Each connection may send many requests; each line //! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. +//! +//! The server only reads requests: a line that is not a JSON object with a string `method` (an +//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is +//! closed, so nothing sent after it runs. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; @@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024; pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { let listener = TcpListener::bind(("127.0.0.1", port))?; + let rx = serve_listener(listener, ctx)?; + log::info!("control channel listening on 127.0.0.1:{port}"); + Ok(rx) +} + +/// Accept connections on `listener`; requests arrive on the returned receiver. +fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result> { let (tx, rx) = channel(); std::thread::Builder::new().name("control".into()).spawn(move || { for stream in listener.incoming().flatten() { @@ -21,47 +32,184 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result, max: usize) -> Result { + buf.clear(); + // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(false); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + return Ok(true); + } + if buf.len() > max { + return Err(format!("request too large (over {max} bytes); closing the connection")); + } + // Last line without a newline, then end of stream. + Ok(true) +} + +/// One request line, parsed. +enum Line { + Request { + id: Json, + method: String, + params: Json, + }, + Blank, + /// Not a request: reply with this error and close the connection. + Reject(String), +} + +fn parse_line(bytes: &[u8]) -> Line { + let Ok(text) = std::str::from_utf8(bytes) else { + return Line::Reject("not a control request (invalid UTF-8); closing the connection".into()); + }; + let text = text.trim(); + if text.is_empty() { + return Line::Blank; + } + if is_http_request_line(text) { + return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into()); + } + let msg = match serde_json::from_str::(text) { + Ok(v) => v, + Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")), + }; + let Some(obj) = msg.as_object() else { + return Line::Reject("not a control request (expected a JSON object); closing the connection".into()); + }; + let Some(method) = obj.get("method").and_then(Json::as_str) else { + return Line::Reject("not a control request (missing string `method`); closing the connection".into()); + }; + Line::Request { + id: obj.get("id").cloned().unwrap_or(Json::Null), + method: method.to_string(), + params: obj.get("params").cloned().unwrap_or(json!({})), + } +} + +/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`). +fn is_http_request_line(text: &str) -> bool { + const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"]; + text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method)) + || text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/")) +} + fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - let reader = BufReader::new(read); - for line in reader.lines() { - let Ok(line) = line else { break }; - if line.trim().is_empty() { - continue; - } - if line.len() > MAX_LINE { - let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"})); - continue; - } - let msg: Json = match serde_json::from_str(&line) { - Ok(v) => v, - Err(e) => { - let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); - continue; - } + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { + Ok(false) => break, + // The peer is gone before sending a line: nobody to tell. + Err(_) if buf.is_empty() => break, + Err(e) => e, + Ok(true) => match parse_line(&buf) { + Line::Blank => continue, + Line::Reject(e) => e, + Line::Request { id, method, params } => { + let (req, rx) = ControlRequest::new(method, params); + if tx.send(req).is_err() { + break; + } + // Wake the UI thread: egui only runs frames when something happens. + ctx.request_repaint(); + let mut reply = rx + .recv_timeout(Duration::from_secs(60)) + .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); + if let Some(o) = reply.as_object_mut() { + o.insert("id".into(), id); + } + if writeln!(out, "{reply}").is_err() { + break; + } + continue; + } + }, }; - let id = msg.get("id").cloned().unwrap_or(Json::Null); - let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string(); - let params = msg.get("params").cloned().unwrap_or(json!({})); - let (req, rx) = ControlRequest::new(method, params); - if tx.send(req).is_err() { - break; + // The connection closes either way; a peer that can't read the reason loses nothing. + let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})); + break; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Send `input` on a new connection and read reply lines until the server closes it. + fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec { + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(input).unwrap(); + // A read timeout ends the list too: the server kept the connection open. + BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect() + } + + fn serve_on_loopback() -> (std::net::SocketAddr, Receiver) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + (addr, serve_listener(listener, egui::Context::default()).unwrap()) + } + + /// A browser can POST to the port: its request line closes the connection before the JSON + /// line in its body is read, so nothing is dispatched. + #[test] + fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { + let (addr, rx) = serve_on_loopback(); + let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n"; + let replies = exchange(addr, http); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); + assert!(rx.try_recv().is_err(), "the body was dispatched"); + } + + #[test] + fn a_line_that_is_not_a_request_closes_the_connection() { + let (addr, rx) = serve_on_loopback(); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true})); + } + }); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap(); + let mut r = BufReader::new(c); + for id in [1, 2] { + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["id"], id, "{line}"); } - // Wake the UI thread: egui only runs frames when something happens. - ctx.request_repaint(); - let mut reply = rx - .recv_timeout(Duration::from_secs(60)) - .unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})); - if let Some(o) = reply.as_object_mut() { - o.insert("id".into(), id); + for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { + let mut input = junk.to_vec(); + input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n"); + let replies = exchange(addr, &input); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); } - if writeln!(out, "{reply}").is_err() { - break; - } + assert!(is_http_request_line("OPTIONS * HTTP/1.1")); + assert!(is_http_request_line("PRI * HTTP/2.0")); + assert!(!is_http_request_line("{\"method\":\"GET /\"}")); + } + + #[test] + fn an_overlong_line_is_refused_without_reading_the_rest() { + let mut buf = Vec::new(); + let long = [b'x'; 100]; + let mut input: &[u8] = &long; + assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); } } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index f623edbc0..0fb700afa 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1314,3 +1314,31 @@ fn large_bounded_references_keep_their_shape() { assert_eq!(&t.get(&at), want, "{formula}"); } } + +#[test] +fn median_large_values_recalculate_dependents() { + let mut t = T::new(); + t.set("A1", "1e308"); + t.set("A2", "1.6e308"); + t.set("B1", "=MEDIAN(A1:A2)"); + t.set("B2", "=AGGREGATE(12,0,A1:A2)"); + t.set("C1", "=B1/1e308"); + t.set("C2", "=B2/1e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) - 1.3).abs() < 1e-14); + } + t.set("A2", "-1e308"); + for cell in ["B1", "B2", "C1", "C2"] { + assert_eq!(t.num(cell), 0.0); + } + t.set("A1", "-1.6e308"); + for cell in ["B1", "B2"] { + assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14); + } + for cell in ["C1", "C2"] { + assert!((t.num(cell) + 1.3).abs() < 1e-14); + } +} diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 8a441fa1b..06050f7e8 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?, Json::Object(_) => { let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?; - add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?; + let func = match str_param(f, "func") { + Some(fname) => { + Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?) + } + None => None, + }; + add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?; let by = group_param(str_param(f, "group")); if let Some(by) = by.filter(|b| *b != PivotDateGroup::None) && matches!(area, "rows" | "columns") diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..8350e56d7 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -650,6 +650,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option String { /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); + value_text_as(wb, &sheet.value(c), style.num_fmt.as_str()) +} + +/// [`value_text`] for a value and a format code given apart from a cell (print takes the code +/// from the cell's effective look, which a conditional format can override). +pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String { + let f = format(v, code, wb); match f.fill { Some((_, pos)) => { let mut t = f.text; diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/pivot_unknown_func.rs new file mode 100644 index 000000000..f4d2427f0 --- /dev/null +++ b/crates/engine/tests/pivot_unknown_func.rs @@ -0,0 +1,33 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap(); + s +} + +#[test] +fn insert_pivot_table_rejects_unknown_func() { + for func in ["stdev", "sumx"] { + let mut s = session(); + let r = s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}), + ); + assert!(r.is_err(), "unknown func `{func}` accepted silently"); + assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty()); + } +} + +#[test] +fn insert_pivot_table_accepts_known_func() { + let mut s = session(); + s.execute( + "insert.pivotTable", + json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}), + ) + .unwrap(); + assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1); +} diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 7c50abeaa..9d8642e24 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -54,7 +54,7 @@ fn median(v: Vec) -> R { let s = sorted(v); let n = s.len(); let hi = s.get(n / 2).copied().unwrap_or(0.0); - if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) } + if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) } } /// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count. @@ -2255,6 +2255,46 @@ mod tests { av(col(xs)) } + // The two middle values are finite and their midpoint is representable even when + // adding them first would overflow. Exercise both callers of the shared helper. + #[test] + fn median_finite_midpoint_does_not_overflow() { + for (values, expected) in [ + (vec![1e308, 1e308], 1e308), + (vec![-1e308, -1e308], -1e308), + (vec![1.6e308, 1e308], 1.3e308), + (vec![-1e308, -1.6e308], -1.3e308), + (vec![f64::MAX, f64::MAX], f64::MAX), + (vec![-f64::MAX, -f64::MAX], -f64::MAX), + (vec![-f64::MAX, f64::MAX], 0.0), + (vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308), + ] { + close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected); + close(ev("MEDIAN", vec![rf(col(&values))]), expected); + close(aggregate_values(12, &values.iter().copied().map(nv).collect::>()), expected); + } + } + + #[test] + fn median_midpoint_keeps_small_values_and_argument_semantics() { + // Halving each operand unconditionally can lose the smallest numbers. Use exact + // equality here: the usual aggregate-test tolerance would accept zero. + for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] { + assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value)); + assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value)); + } + close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5); + close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0); + close(ev("MEDIAN", vec![n(1e308)]), 1e308); + close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5); + close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5); + is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value); + is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA); + is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num); + is_err(aggregate_values(12, &[]), CellError::Num); + is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0); + } + #[test] fn averages_and_counts() { close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 00e0c18be..90a39cb06 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -482,6 +482,10 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> ( if st.font.strike { style.font.strike = true; } + // In a differential style General means "not set" (#1163). + if st.num_fmt.as_str() != "General" { + style.num_fmt = st.num_fmt.clone(); + } for (dst, src) in [ (&mut style.borders.left, st.borders.left), (&mut style.borders.right, st.borders.right), @@ -3406,6 +3410,28 @@ mod tests { assert_eq!(center_across_end(0, 1, |_| true), 1); } + #[test] + fn conditional_number_format_reaches_the_cell_text() { + // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs. + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap(); + session.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap(); + let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}}); + session.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap(); + let wb = session.active().unwrap().wb.clone(); + let sh = wb.sheet(0).unwrap(); + let mut cf = CfCache::new(); + let text = |row: u32, cf: &mut CfCache| { + let c = CellRef::new(row, 0); + let (lk, _) = look(&wb, 0, sh, c, cf); + display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0 + }; + assert_eq!(text(0, &mut cf), "1"); + assert_eq!(text(1, &mut cf), "3.00"); + assert_eq!(text(2, &mut cf), "4.00"); + } + #[test] fn filter_menu_uses_the_table_whose_header_was_clicked() { use gridcraft_engine::model::Table; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 485b9a2f3..08097ba27 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -252,7 +252,8 @@ fn candidates(role: &str) -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF"]; + // ChromeOS's Linux container (Crostini) mounts the host's fonts under `chromeos/` (#1762). + let lin = ["/usr/share/fonts/truetype", "/usr/share/fonts", "/usr/local/share/fonts", "/usr/share/fonts/TTF", "/usr/share/fonts/chromeos"]; let mut v: Vec<(String, u32)> = Vec::new(); let mut add = |s: String, i: u32| v.push((s, i)); match role { @@ -498,10 +499,10 @@ fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, -/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and -/// flat layouts. +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), ChromeOS's Linux +/// container (`chromeos/notocjk`, #1762), plus local and flat layouts. #[cfg(not(target_arch = "wasm32"))] -const LINUX_CJK_DIRS: [&str; 10] = [ +const LINUX_CJK_DIRS: [&str; 11] = [ "/usr/share/fonts/opentype/noto", "/usr/share/fonts/google-noto-cjk", "/usr/share/fonts/noto-cjk", @@ -512,6 +513,7 @@ const LINUX_CJK_DIRS: [&str; 10] = [ "/usr/share/fonts", "/usr/local/share/fonts", "/usr/local/share/fonts/noto-cjk", + "/usr/share/fonts/chromeos/notocjk", ]; /// Japanese Han + Kana faces (Japanese kanji glyph shapes). @@ -581,7 +583,13 @@ fn thai() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma @@ -603,7 +611,13 @@ fn hebrew() -> Vec<(String, u32)> { let mac_sys = "/System/Library/Fonts"; let mac_sup = "/System/Library/Fonts/Supplemental"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let lin = [ + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts", + "/usr/share/fonts/chromeos/noto", // ChromeOS's Linux container (#1762) + ]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\david.ttf"), 0)); // David v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl @@ -657,6 +671,7 @@ fn symbols_more() -> Vec<(String, u32)> { "/usr/share/fonts/google-noto-sans-symbols-2-fonts", "/usr/share/fonts/google-noto", "/usr/local/share/fonts/noto", + "/usr/share/fonts/chromeos/noto", ] { v.push((format!("{d}/NotoSansSymbols2-Regular.ttf"), 0)); } @@ -888,6 +903,22 @@ mod tests { } } + #[test] + fn chromeos_font_layout_is_probed() { + // #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per + // family set; a script whose list misses it renders as tofu with the font installed. + let probes = |cands: Vec<(String, u32)>, path: &str| cands.iter().any(|(p, _)| p == path); + let cros = "/usr/share/fonts/chromeos"; + assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf"))); + assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf"))); + assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc"))); + assert!(probes(symbols_more(), &format!("{cros}/noto/NotoSansSymbols2-Regular.ttf"))); + assert!(probes(candidates(UI), &format!("{cros}/noto/NotoSans-Regular.ttf"))); + assert!(probes(candidates(CELL), &format!("{cros}/crosextra/Carlito-Regular.ttf"))); + } + #[test] fn unparseable_font_files_are_rejected_not_fatal() { // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..e82ae7299 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -15,6 +15,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on Errors come back as `{"id": …, "ok": false, "error": "…"}`. +A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON, +bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the +connection; nothing sent after it runs. Blank lines are skipped. + ## Methods | Method | Params | What it does | diff --git a/docs/function-parity.md b/docs/function-parity.md index d262b5919..7eccd3892 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (MEDIAN and AGGREGATE median avoid intermediate overflow) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -101,6 +101,11 @@ fraction sections with literal text (`#\:#=/=#`, `#_#/#`), zero fractions, zero- denominators, `?` placeholders padding the thousands separator, and booleans through the text section (`TEXT(TRUE,";;;\@@\@")` = `@TRUE@`). +`MEDIAN` and `AGGREGATE` function number 12 use an overflow-safe midpoint for even-sized +inputs, so large finite middle values do not become `#NUM!` merely because their sum overflows (#1579). +Sorting, argument coercion and error propagation are unchanged; this is a numerical correction +under the documented median rule, not an extreme-value comparison against desktop Excel. + Work to close (16–26 h in total): the in-scope missing functions (10–16 h with the table above), an oracle corpus of Excel-computed results per function (owner runs Excel on generated workbooks; results checked in as data, not Excel files) and fixing what it finds (6–10 h). @@ -109,6 +114,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | MEDIAN and AGGREGATE median use an overflow-safe midpoint (#1579); regressions cover finite extremes, tiny values and dependent recalculation | | 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) | | 2026-10-14 | minor | Bounded references up to 1,048,576 cells (`D3:AZ400`) keep their full shape instead of stopping at the used range, so `SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=x)*(D1:AZ1=y))` broadcasts like Excel and `ROWS`, `COUNTBLANK` and `--(r="")` count the blanks; references that run to the edge of the sheet (`A:A`) still stop at the used range (#1010) | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |