Merge main into #862
# Conflicts: # crates/ui-egui/tests/interaction.rs
This commit is contained in:
commit
72e7590c8c
20
ROADMAP.md
20
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink survives XLSX saves) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
|
|
@ -132,6 +132,19 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
| 2026-10-12 | Security: cell hyperlinks open only http, https and mailto, after a confirmation; other schemes (file:, smb:, UNC, custom handlers) are refused (#777, part 1) |
|
||||
| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) |
|
||||
| 2026-10-12 | XLSX chart sheets are kept on open and save: the sheet, its drawing and chart (with style and colours) are written back unchanged in their place among the worksheets, and left out when the sheet they plot is deleted or renamed or a worksheet took their name (#727) |
|
||||
| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) |
|
||||
| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) |
|
||||
| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) |
|
||||
| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) |
|
||||
| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) |
|
||||
| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) |
|
||||
| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) |
|
||||
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
||||
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
|
|
@ -179,6 +192,11 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) |
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@
|
|||
//! them are skipped.
|
||||
//!
|
||||
//! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is
|
||||
//! validated (content types cover every part, every internal relationship target exists) and read
|
||||
//! back, and the two workbooks are compared feature by feature.
|
||||
//! validated (content types cover every part, every internal relationship target exists, no kind of
|
||||
//! relationship the source had is missing) and read back, and the two workbooks are compared feature
|
||||
//! by feature.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read as _};
|
||||
|
|
@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000;
|
|||
const MAX_XML_PART: u64 = 64 * 1024 * 1024;
|
||||
/// Most zip entries looked at while validating a written package.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
|
||||
/// a cache Excel rebuilds, and GridCraft never writes it.
|
||||
const REBUILT_KINDS: [&str; 1] = ["calcchain"];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Options {
|
||||
|
|
@ -289,7 +293,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
|||
calc.recalc_all(&mut fresh);
|
||||
let trust_cache = !full_calc_on_load(bytes);
|
||||
report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts);
|
||||
report.round_trip = round_trip(&fresh, opts);
|
||||
report.round_trip = round_trip(&fresh, bytes, opts);
|
||||
report
|
||||
}
|
||||
|
||||
|
|
@ -528,8 +532,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
|
|||
|
||||
// ---------------------------------------------------------------- round trip
|
||||
|
||||
/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares.
|
||||
pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
|
||||
/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against
|
||||
/// `source` (the file `wb` was read from), reads it back and compares.
|
||||
pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip {
|
||||
let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") {
|
||||
Ok(b) => b,
|
||||
Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] },
|
||||
|
|
@ -539,6 +544,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
|
|||
Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)),
|
||||
Err(e) => package.push(|| e),
|
||||
}
|
||||
if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) {
|
||||
for (key, kind) in &before {
|
||||
if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) {
|
||||
package.push(|| format!("lost relationship {kind}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) {
|
||||
Ok((b, _)) => b,
|
||||
Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] },
|
||||
|
|
@ -915,6 +927,77 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
|
|||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Every relationship kind (last segment of the type, the same in the transitional and strict
|
||||
/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry
|
||||
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
|
||||
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
|
||||
fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
|
||||
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
|
||||
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
|
||||
let target = attr(attrs, "Target").unwrap_or("");
|
||||
if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if !external
|
||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||
&& !holds_something(&mut zip, part, kind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string());
|
||||
}
|
||||
}
|
||||
Ok(kinds)
|
||||
}
|
||||
|
||||
/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the
|
||||
/// empty string for the package's `_rels/.rels`.
|
||||
fn rels_source_dir(key: &str) -> Option<String> {
|
||||
let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?;
|
||||
Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let mut in_root = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let found = match wanted {
|
||||
Some(w) => local_name(e.name().as_ref()) == w,
|
||||
None => in_root,
|
||||
};
|
||||
if found {
|
||||
return true;
|
||||
}
|
||||
in_root = true;
|
||||
}
|
||||
Ok(Event::Eof) => return false,
|
||||
Ok(_) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -1338,7 +1421,8 @@ mod tests {
|
|||
fn round_trip_of_a_simple_book_is_clean() {
|
||||
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
|
||||
wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
|
||||
let rt = round_trip(&wb, &Options::default());
|
||||
let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
|
||||
let rt = round_trip(&wb, &source, &Options::default());
|
||||
assert_eq!(rt.error, None);
|
||||
assert!(rt.is_clean(), "{rt:?}");
|
||||
assert!(rt.categories.iter().any(|c| c.name == "package"));
|
||||
|
|
@ -1424,6 +1508,98 @@ mod tests {
|
|||
assert_eq!(validate_package(&own).unwrap(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
|
||||
const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
|
||||
const MADE_UP: &str = r#"<Relationship Id="rId90" Type="http://example.com/relationships/madeUp" Target="madeUp.xml"/>"#;
|
||||
/// Not empty: an empty part isn't a loss.
|
||||
const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", "<madeUp><item/></madeUp>");
|
||||
|
||||
/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
|
||||
fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut pending: Vec<(&str, &str)> = rels.to_vec();
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut body = String::new();
|
||||
f.read_to_string(&mut body).unwrap();
|
||||
if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
|
||||
body = body.replace("</Relationships>", &format!("{}</Relationships>", pending.remove(at).1));
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
let created = pending.iter().map(|(r, extra)| {
|
||||
(*r, format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{extra}</Relationships>"#))
|
||||
});
|
||||
for (name, body) in created.collect::<Vec<_>>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The `package` examples of checking `source`.
|
||||
fn package_of(source: &[u8]) -> Vec<String> {
|
||||
let r = check_bytes("t.xlsx", source, &Options::default());
|
||||
r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
|
||||
}
|
||||
|
||||
fn plain() -> Vec<u8> {
|
||||
gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_reports_relationship_kinds_the_output_lost() {
|
||||
// A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
|
||||
// (Excel rebuilds it), so its absence isn't a loss.
|
||||
let calc_chain = r#"<Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/>"#;
|
||||
let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "<calcChain/>")]);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
|
||||
}
|
||||
|
||||
// In the three tests below the made-up kind stays lost, so the comparison is known to run.
|
||||
|
||||
#[test]
|
||||
fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
|
||||
// openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
|
||||
let link = r##"<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="#Sheet1!B2" TargetMode="External"/>"##;
|
||||
let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_comment_are_not_a_loss() {
|
||||
let rels = r#"<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#;
|
||||
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList/></comments>"#;
|
||||
let vml = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><o:shapelayout><o:idmap data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" path="m,l,21600r21600,l21600,xe"><v:path gradientshapeok="t"/></v:shapetype></xml>"#;
|
||||
let source = with_rels(
|
||||
&plain(),
|
||||
&[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
|
||||
&[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
|
||||
);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_drawings_and_person_lists_are_not_a_loss() {
|
||||
let drawing = r#"<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>"#;
|
||||
let person =
|
||||
r#"<Relationship Id="rId92" Type="http://schemas.microsoft.com/office/2017/10/relationships/person" Target="persons/person.xml"/>"#;
|
||||
let source = with_rels(
|
||||
&plain(),
|
||||
&[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
|
||||
&[
|
||||
MADE_UP_PART,
|
||||
("xl/drawings/drawing1.xml", r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"/>"#),
|
||||
("xl/persons/person.xml", r#"<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"/>"#),
|
||||
],
|
||||
);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_files_are_reported() {
|
||||
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
|||
let mut s = open(input)?;
|
||||
activate_sheet(&mut s, a.opt("sheet"))?;
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = gridcraft_engine::io::save_bytes(&d.wb, out).map_err(|e| e.to_string())?;
|
||||
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
|
||||
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -170,8 +170,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
|||
}
|
||||
})),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.open_with_url_and_target(url, "_blank");
|
||||
if let Some(w) = web_sys::window()
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
|
|
|||
|
|
@ -201,6 +201,13 @@ fn services() -> Services {
|
|||
|
||||
/// Opens a URL or file with the system's default application.
|
||||
fn open_with_system(target: &str) {
|
||||
// Last line of defence behind `link.open` and the click prompt: the system only ever gets
|
||||
// http, https and mailto addresses.
|
||||
let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else {
|
||||
log::warn!("refused to open {target:?}: only http, https and mailto links are opened");
|
||||
return;
|
||||
};
|
||||
let target = target.as_str();
|
||||
let cmd = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else if cfg!(windows) {
|
||||
|
|
|
|||
|
|
@ -61,3 +61,7 @@ sync_github_name = true
|
|||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ pub struct Evaluator<'h> {
|
|||
|
||||
struct FnCtx<'a, 'h> {
|
||||
ev: &'a mut Evaluator<'h>,
|
||||
/// The criteria argument of a database function, when it is a reference.
|
||||
criteria: Option<Area>,
|
||||
}
|
||||
|
||||
impl Ctx for FnCtx<'_, '_> {
|
||||
|
|
@ -100,6 +102,28 @@ impl Ctx for FnCtx<'_, '_> {
|
|||
fn random(&mut self) -> f64 {
|
||||
self.ev.host.random()
|
||||
}
|
||||
/// Excel's "formula as criteria": the formula is written for the first record, so its relative
|
||||
/// references move down to `record`'s row, and the database's labels name its fields.
|
||||
/// Known gap: the shifted references are not dependencies of the calling cell. One that lands
|
||||
/// outside both the database and the criteria cell's own precedents (say `=G2<H2` with column H
|
||||
/// outside the database) is read here but not tracked, so editing that cell doesn't recalculate
|
||||
/// the D-function until something else does.
|
||||
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
|
||||
let area = self.criteria?;
|
||||
let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;
|
||||
let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;
|
||||
let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);
|
||||
let env = (0..db.cols)
|
||||
.filter_map(|c| match db.get(0, c) {
|
||||
Some(Value::Text(t)) if !t.trim().is_empty() => {
|
||||
Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty))))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
|
|
@ -383,6 +407,7 @@ impl<'h> Evaluator<'h> {
|
|||
};
|
||||
}
|
||||
let v = self.value(x);
|
||||
let sys = self.host.workbook().date_system;
|
||||
Ev::V(map_unary(
|
||||
&v,
|
||||
&|n| match op {
|
||||
|
|
@ -392,6 +417,7 @@ impl<'h> Evaluator<'h> {
|
|||
UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n),
|
||||
},
|
||||
op == UnOp::Plus,
|
||||
sys,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -478,7 +504,7 @@ impl<'h> Evaluator<'h> {
|
|||
let mut acc = self.value(left);
|
||||
while let Some((o, r)) = rest.pop() {
|
||||
let y = self.value(r);
|
||||
acc = binary_values(o, &acc, &y);
|
||||
acc = binary_values(o, &acc, &y, self.host.workbook().date_system);
|
||||
}
|
||||
Ev::V(acc)
|
||||
}
|
||||
|
|
@ -592,7 +618,7 @@ impl<'h> Evaluator<'h> {
|
|||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
|
|
@ -750,6 +776,20 @@ impl<'h> Evaluator<'h> {
|
|||
Ev::V(Value::Bool(r))
|
||||
}
|
||||
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
|
||||
"GROUPBY" | "PIVOTBY" => {
|
||||
// The aggregation may be a function given by name (`SUM`): pass it as its name.
|
||||
let i = if name == "GROUPBY" { 2 } else { 3 };
|
||||
match args.get(i).and_then(|a| self.function_by_name(a)) {
|
||||
Some(f) => {
|
||||
let mut args = args.to_vec();
|
||||
if let Some(a) = args.get_mut(i) {
|
||||
*a = Expr::Text(f.into());
|
||||
}
|
||||
self.builtin(name, &args)
|
||||
}
|
||||
None => self.builtin(name, args),
|
||||
}
|
||||
}
|
||||
"NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => {
|
||||
self.host.note_volatile();
|
||||
self.builtin(name, args)
|
||||
|
|
@ -775,8 +815,17 @@ impl<'h> Evaluator<'h> {
|
|||
let mut evaluated = Vec::with_capacity(args.len());
|
||||
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
|
||||
let mut criteria_shape = None;
|
||||
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
|
||||
let mut criteria = None;
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
let mut ev = self.eval(a);
|
||||
if spec.category == gridcraft_functions::Category::Database
|
||||
&& i == 2
|
||||
&& let Ev::R(areas) = &ev
|
||||
&& let [area] = areas.as_slice()
|
||||
{
|
||||
criteria = Some(*area);
|
||||
}
|
||||
if resizes_sum_range(name, args.len()) {
|
||||
if i == 0 {
|
||||
criteria_shape = ev_shape(&ev);
|
||||
|
|
@ -804,7 +853,7 @@ impl<'h> Evaluator<'h> {
|
|||
};
|
||||
evaluated.push(Arg { value, from_ref });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria };
|
||||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
}
|
||||
|
||||
|
|
@ -858,13 +907,13 @@ impl<'h> Evaluator<'h> {
|
|||
};
|
||||
let args = [Arg { value, from_ref }];
|
||||
if kind != Agg::Count {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
Value::Number(n) => acc.sum += n,
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
}
|
||||
if kind != Agg::Sum {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
|
|
@ -921,7 +970,26 @@ impl<'h> Evaluator<'h> {
|
|||
r
|
||||
}
|
||||
|
||||
fn lambda_arg(&mut self, e: Option<&Expr>) -> Option<Arc<Lambda>> {
|
||||
/// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`,
|
||||
/// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has
|
||||
/// that name.
|
||||
fn function_by_name(&self, e: &Expr) -> Option<String> {
|
||||
let Expr::Name(n) = e else { return None };
|
||||
if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() {
|
||||
return None;
|
||||
}
|
||||
let u = n.to_ascii_uppercase();
|
||||
gridcraft_functions::lookup(&u).map(|_| u)
|
||||
}
|
||||
|
||||
/// The function argument of a LAMBDA helper; a function given by name takes `arity`
|
||||
/// arguments.
|
||||
fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option<Arc<Lambda>> {
|
||||
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
|
||||
let params: Vec<String> = (0..arity).map(|i| format!("_xleta.{i}")).collect();
|
||||
let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect());
|
||||
return Some(Arc::new(Lambda { params, body, env: Vec::new() }));
|
||||
}
|
||||
match e.map(|x| self.eval(x)) {
|
||||
Some(Ev::L(l)) => Some(l),
|
||||
_ => None,
|
||||
|
|
@ -940,7 +1008,7 @@ impl<'h> Evaluator<'h> {
|
|||
if args.len() < 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) };
|
||||
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
|
||||
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
|
||||
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
|
||||
|
|
@ -963,7 +1031,7 @@ impl<'h> Evaluator<'h> {
|
|||
}
|
||||
let mut acc = self.value(&args[0]);
|
||||
let arr = as_array(self.value(&args[1]));
|
||||
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
|
||||
if l.params.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
|
|
@ -987,7 +1055,7 @@ impl<'h> Evaluator<'h> {
|
|||
return err(CellError::Value);
|
||||
}
|
||||
let arr = as_array(self.value(&args[0]));
|
||||
let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
|
||||
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
|
||||
let by_row = name == "BYROW";
|
||||
let n = if by_row { arr.rows } else { arr.cols };
|
||||
|
|
@ -1016,7 +1084,7 @@ impl<'h> Evaluator<'h> {
|
|||
if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
|
||||
if l.params.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
|
|
@ -1099,16 +1167,18 @@ impl<'h> Evaluator<'h> {
|
|||
v.scalar().to_bool().unwrap_or(true)
|
||||
}
|
||||
};
|
||||
let text = if a1 {
|
||||
text
|
||||
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
|
||||
let (text, a1_ok) = if a1 {
|
||||
(text, true)
|
||||
} else {
|
||||
match r1c1_to_a1(&text, self.at) {
|
||||
Ok(Some(a1_text)) => a1_text,
|
||||
Ok(None) => text,
|
||||
Ok(Some(a1_text)) => (a1_text, true),
|
||||
Ok(None) => (text, false),
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
};
|
||||
match gridcraft_formula::parse(&text) {
|
||||
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
|
||||
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
|
||||
r @ Ev::R(_) => r,
|
||||
_ => err(CellError::Ref),
|
||||
|
|
@ -1248,7 +1318,7 @@ impl<'h> Evaluator<'h> {
|
|||
}
|
||||
}
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
|
||||
Value::Number(p) if p >= 1.0 => {
|
||||
let k = p as u32 - 1;
|
||||
|
|
@ -1302,7 +1372,7 @@ impl<'h> Evaluator<'h> {
|
|||
_ => "v",
|
||||
}))
|
||||
}
|
||||
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())),
|
||||
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
|
||||
"filename" => Ev::V(Value::text(String::new())),
|
||||
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
|
||||
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
|
||||
|
|
@ -1527,12 +1597,12 @@ impl Acc {
|
|||
|
||||
// ---------------------------------------------------------------- operators
|
||||
|
||||
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value {
|
||||
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
|
||||
match v {
|
||||
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus)).collect() }),
|
||||
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
|
||||
Value::Error(e) => Value::Error(*e),
|
||||
Value::Text(_) if plus => v.clone(),
|
||||
other => match other.to_number() {
|
||||
other => match other.to_number_in(sys) {
|
||||
Ok(n) => f(n),
|
||||
Err(e) => Value::Error(e),
|
||||
},
|
||||
|
|
@ -1585,18 +1655,18 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
|||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value {
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b))
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
|
||||
}
|
||||
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value {
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if let Value::Error(e) = a {
|
||||
return Value::Error(*e);
|
||||
}
|
||||
if let Value::Error(e) = b {
|
||||
return Value::Error(*e);
|
||||
}
|
||||
let num = |f: fn(f64, f64) -> Value| match (a.to_number(), b.to_number()) {
|
||||
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
|
||||
(Ok(x), Ok(y)) => f(x, y),
|
||||
(Err(e), _) | (_, Err(e)) => Value::Error(e),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -290,6 +290,26 @@ fn circular_references_without_iterative_calculation() {
|
|||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_coerces_in_the_workbook_date_system() {
|
||||
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
||||
let mut t = T::new();
|
||||
t.wb.date_system = sys;
|
||||
t.set("A1", "'1/1/1904");
|
||||
t.set("B1", "=A1+0");
|
||||
t.set("C1", "=VALUE(A1)");
|
||||
t.set("D1", "=-A1");
|
||||
t.set("E1", "=DATEVALUE(A1)");
|
||||
t.set("F1", "=NUMBERVALUE(A1)");
|
||||
assert_eq!(t.get("A1"), Value::from("1/1/1904"));
|
||||
assert_eq!(t.num("B1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("C1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("D1"), -serial, "{sys:?}");
|
||||
assert_eq!(t.num("E1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("F1"), serial, "{sys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
|
|
@ -423,6 +443,22 @@ fn references_functions() {
|
|||
assert_eq!(t.num("B3"), 50.0);
|
||||
t.set("B4", "=INDIRECT(\"A\"&4)");
|
||||
assert_eq!(t.num("B4"), 40.0);
|
||||
// With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1.
|
||||
t.set("C1", "=INDIRECT(\"R3C1\",FALSE)");
|
||||
assert_eq!(t.num("C1"), 30.0);
|
||||
t.set("C2", "=INDIRECT(\"A3\",FALSE)");
|
||||
assert_eq!(t.get("C2"), Value::Error(CellError::Ref));
|
||||
// Defined names don't depend on the reference style.
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Third".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$A$3".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.set("C3", "=INDIRECT(\"Third\",FALSE)");
|
||||
assert_eq!(t.num("C3"), 30.0);
|
||||
t.set("B5", "=SUM(A1:INDEX(A1:A4,3))");
|
||||
assert_eq!(t.num("B5"), 60.0);
|
||||
t.set("B6", "=SUM(A:A)");
|
||||
|
|
@ -552,6 +588,27 @@ fn let_lambda() {
|
|||
assert_eq!(t.num("A4"), 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_given_by_name() {
|
||||
let mut t = T::new();
|
||||
for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), k);
|
||||
t.set(&format!("B{}", i + 1), &v.to_string());
|
||||
}
|
||||
t.set("D1", "=BYROW(B1:B3,SUM)");
|
||||
assert_eq!(t.num("D2"), -2.0);
|
||||
t.set("E1", "=MAP(B1:B3,ABS)");
|
||||
assert_eq!(t.num("E2"), 2.0);
|
||||
t.set("F1", "=REDUCE(0,B1:B3,SUM)");
|
||||
assert_eq!(t.num("F1"), 2.0);
|
||||
t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)");
|
||||
assert_eq!(t.get("G1"), Value::from("a"));
|
||||
assert_eq!(t.num("H1"), 4.0);
|
||||
// A LET variable of that name is still the variable.
|
||||
t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))");
|
||||
assert_eq!(t.num("J1"), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_argument_count() {
|
||||
let mut t = T::new();
|
||||
|
|
@ -1103,6 +1160,73 @@ fn trim_references() {
|
|||
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_width_is_the_column_width_dialog_value_rounded() {
|
||||
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
|
||||
// (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64).
|
||||
let mut t = T::new();
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] {
|
||||
sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() });
|
||||
}
|
||||
for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] {
|
||||
t.set(at, &format!("=CELL(\"width\",{col}1)"));
|
||||
}
|
||||
assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding
|
||||
/// a formula under a label that names no field is evaluated for every record.
|
||||
#[test]
|
||||
fn database_functions_with_computed_criteria() {
|
||||
let mut t = T::new();
|
||||
let db = [
|
||||
["name", "age", "size", "headgear", "glasses"],
|
||||
["Huey", "6", "45", "cap~", "FALSE"],
|
||||
["Dewey", "7", "fourty-six", "cap?", "FALSE"],
|
||||
["Louie", "8", "47", "cap*", "FALSE"],
|
||||
["Donald", "32", "70", "sailor's hat", "FALSE"],
|
||||
["Scrooge", "62", "65", "top hat", "TRUE"],
|
||||
];
|
||||
for (r, row) in db.iter().enumerate() {
|
||||
for (c, v) in row.iter().enumerate() {
|
||||
t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v);
|
||||
}
|
||||
}
|
||||
t.set("G45", "7");
|
||||
let cases = [
|
||||
// Relative references are to the first record's row and move to each record's row;
|
||||
// `$G$45` stays put.
|
||||
("G", "=G2<$G$45", "Dewey"),
|
||||
// Column labels stand for the record's fields.
|
||||
("H", "=glasses=TRUE", "Scrooge"),
|
||||
("I", "=age<7", "Huey"),
|
||||
("J", "=size/7=10", "Donald"),
|
||||
// An absolute reference to a cell that is not the first record's.
|
||||
("K", "=H3=$L$1", "Donald"),
|
||||
];
|
||||
t.set("L1", "70");
|
||||
for (i, (col, formula, _)) in cases.iter().enumerate() {
|
||||
t.set(&format!("{col}46"), "Formula");
|
||||
t.set(&format!("{col}47"), formula);
|
||||
t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)"));
|
||||
}
|
||||
for (i, (_, formula, want)) in cases.iter().enumerate() {
|
||||
assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}");
|
||||
}
|
||||
// Every database function takes them, ANDed with the other criteria of their row.
|
||||
// Text sorts after numbers, so Dewey's "fourty-six" is > 40.
|
||||
t.set("L47", "=size>40");
|
||||
t.set("M46", "age");
|
||||
t.set("M47", ">6");
|
||||
t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)");
|
||||
t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)");
|
||||
t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)");
|
||||
assert_eq!(t.num("B51"), 1.0);
|
||||
assert_eq!(t.num("B52"), 7.0);
|
||||
assert_eq!(t.num("B53"), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_extreme_arguments_return_ref() {
|
||||
let wb = Workbook::new();
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ impl Fmt {
|
|||
pub fn new(code: &str) -> Fmt {
|
||||
let t = code.trim();
|
||||
let general = t.is_empty() || t.eq_ignore_ascii_case("general");
|
||||
Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) }
|
||||
Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) }
|
||||
}
|
||||
/// Formats `v`; `step` (axis labels) fixes the decimals for General.
|
||||
pub fn format(&self, v: f64, step: Option<f64>) -> String {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ impl Measure for ApproxMeasure {
|
|||
pub struct ChartData {
|
||||
pub categories: Vec<String>,
|
||||
pub series: Vec<SeriesData>,
|
||||
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
|
||||
#[serde(default)]
|
||||
pub palette: Vec<Rgba>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
|
|||
fn point_color(data: &ChartData, j: usize) -> Rgba {
|
||||
match (j, data.series.first()) {
|
||||
(0, Some(s)) => s.color,
|
||||
_ => series_color(&Theme::default(), j),
|
||||
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
|
|||
|
||||
fn sanitize(d: &ChartData) -> ChartData {
|
||||
ChartData {
|
||||
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
|
||||
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
series: d
|
||||
.series
|
||||
|
|
@ -254,7 +255,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
|||
Doughnut => pie(ctx, chart, data, area, 0.5, false),
|
||||
Sunburst => pie(ctx, chart, data, area, 0.2, true),
|
||||
Treemap => treemap(ctx, chart, data, area),
|
||||
Funnel => funnel(ctx, data, area),
|
||||
Funnel => funnel(ctx, chart, data, area),
|
||||
Radar => radar(ctx, chart, data, area),
|
||||
_ => {
|
||||
let area = axis_titles(ctx, chart, area);
|
||||
|
|
@ -266,8 +267,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
|||
Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),
|
||||
Histogram => histogram(ctx, chart, data, area),
|
||||
Waterfall => waterfall(ctx, chart, data, area),
|
||||
BoxWhisker => box_whisker(ctx, data, area),
|
||||
Stock => stock(ctx, data, area),
|
||||
BoxWhisker => box_whisker(ctx, chart, data, area),
|
||||
Stock => stock(ctx, chart, data, area),
|
||||
_ => cartesian(ctx, chart, data, area, false),
|
||||
}
|
||||
}
|
||||
|
|
@ -1315,7 +1316,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
|||
|
||||
// ---------------------------------------------------------------- funnel
|
||||
|
||||
fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some(s) = data.series.first() else { return };
|
||||
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
|
||||
if n == 0 {
|
||||
|
|
@ -1345,9 +1346,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
|||
}
|
||||
if show {
|
||||
let yc = y + bh / 2.0;
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
|
||||
if chart.data_labels {
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
|
||||
}
|
||||
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
|
||||
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
|
||||
}
|
||||
|
|
@ -1571,12 +1574,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 {
|
|||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
|
||||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1615,7 +1618,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
|||
|
||||
// ---------------------------------------------------------------- stock
|
||||
|
||||
fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
|
||||
if ncat == 0 {
|
||||
return;
|
||||
|
|
@ -1624,7 +1627,7 @@ fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
|||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, true);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16;
|
|||
|
||||
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
|
||||
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
|
||||
/// `series_color` repeats after this many colours (six accents × the tints).
|
||||
pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len();
|
||||
|
||||
/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
|
||||
pub fn series_color(theme: &Theme, i: usize) -> Rgba {
|
||||
|
|
@ -145,7 +147,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
|||
if let Some(c) = at {
|
||||
let code = &wb.styles.get(s.style_id(c)).num_fmt.0;
|
||||
if !code.eq_ignore_ascii_case("general") {
|
||||
return format_value(v, &NumberFormat::parse(code), wb.date_system).text;
|
||||
return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
|||
}
|
||||
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
|
||||
ChartData { categories, series }
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, series, palette }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
|||
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
|
||||
b.secondary = kind == ChartKind::Combo;
|
||||
ChartData {
|
||||
palette: vec![],
|
||||
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),
|
||||
|
|
@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() {
|
|||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x, None, "{kind:?}");
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
|
||||
// Numeric X values are kept.
|
||||
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
|
||||
|
|
@ -455,7 +456,8 @@ fn pie_starts_at_twelve_clockwise() {
|
|||
#[test]
|
||||
fn negative_values_put_axis_below_zero() {
|
||||
let c = chart(ChartKind::ColumnClustered);
|
||||
let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
|
||||
let d =
|
||||
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let t = texts(&prims);
|
||||
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
|
||||
|
|
@ -466,8 +468,13 @@ fn negative_values_put_axis_below_zero() {
|
|||
fn edge_cases_never_panic() {
|
||||
let m = ApproxMeasure;
|
||||
let empty = ChartData::default();
|
||||
let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] };
|
||||
let zeros = ChartData {
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into()],
|
||||
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
|
||||
};
|
||||
let weird = ChartData {
|
||||
palette: vec![],
|
||||
categories: vec![String::new(); 3],
|
||||
series: vec![SeriesData {
|
||||
name: "w".repeat(500),
|
||||
|
|
@ -498,7 +505,7 @@ fn thousands_of_points_are_decimated() {
|
|||
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.data_labels = true;
|
||||
let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
|
||||
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
|
||||
let max_line = prims
|
||||
.iter()
|
||||
|
|
@ -523,7 +530,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
|||
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = smooth;
|
||||
let d = ChartData { categories: cats.clone(), series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
|
|
@ -540,7 +547,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
|||
// Rendering either way must stay finite and inside the canvas.
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
let d = ChartData { categories: cats, series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
|
||||
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
|
||||
}
|
||||
|
||||
|
|
@ -550,7 +557,7 @@ fn smooth_line_splits_at_gaps() {
|
|||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
s.values[1] = None; // gap between two data points
|
||||
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
|
||||
// Two separate runs (before/after the gap) → at least two line primitives.
|
||||
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
|
||||
|
|
@ -627,7 +634,7 @@ fn fuzz_random_data_and_sizes() {
|
|||
})
|
||||
.collect();
|
||||
let ncat = (r.next() % 50) as usize;
|
||||
let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
|
||||
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
|
||||
let mut c = chart(kind);
|
||||
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
|
||||
c.data_labels = r.next().is_multiple_of(2);
|
||||
|
|
@ -802,7 +809,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
|||
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.trendlines =
|
||||
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
|
|
@ -826,7 +833,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
|||
assert!(lines_of(&pie, tc).is_empty());
|
||||
// One point: nothing to fit, nothing drawn.
|
||||
s.values.truncate(1);
|
||||
let one = ChartData { categories: vec!["a".into()], series: vec![s] };
|
||||
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -846,7 +853,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
|||
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
|
||||
];
|
||||
let d = ChartData { categories: vec![], series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
|
||||
let mut c = chart(ChartKind::Scatter);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
|
|
@ -861,6 +868,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
|||
#[test]
|
||||
fn treemap_and_histogram_shapes() {
|
||||
let d = ChartData {
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
|
||||
};
|
||||
|
|
@ -881,7 +889,7 @@ fn treemap_and_histogram_shapes() {
|
|||
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
|
||||
|
||||
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
|
|
@ -890,7 +898,7 @@ fn treemap_and_histogram_shapes() {
|
|||
#[test]
|
||||
fn histogram_boundary_values_match_interval_labels() {
|
||||
let heights = |vals: &[f64]| -> Vec<f32> {
|
||||
let d = ChartData { categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
|
||||
};
|
||||
|
|
@ -935,6 +943,95 @@ fn union_series_references() {
|
|||
let _ = resolve(&wb, 0, &c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_and_stock_charts_honour_the_gridlines_flag() {
|
||||
// Horizontal full-width lines in the gridline colour are the value-axis gridlines.
|
||||
let grid: Rgba = [217, 217, 217, 255];
|
||||
let is_grid = |p: &Prim| match p {
|
||||
Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0,
|
||||
_ => false,
|
||||
};
|
||||
let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count();
|
||||
for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] {
|
||||
let mut d = sample(kind);
|
||||
d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind));
|
||||
let mut c = chart(kind);
|
||||
let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
|
||||
c.gridlines = false;
|
||||
let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
|
||||
assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines");
|
||||
assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn funnel_value_labels_follow_the_data_labels_flag() {
|
||||
let data = ChartData {
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into()],
|
||||
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
|
||||
};
|
||||
let mut c = chart(ChartKind::Funnel);
|
||||
c.data_labels = false;
|
||||
let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
|
||||
c.data_labels = true;
|
||||
let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
|
||||
let values = ["$11,111", "$22,222", "$33,333"];
|
||||
for v in values {
|
||||
assert!(texts(&on).contains(&v), "{v} missing with labels on");
|
||||
assert!(!texts(&off).contains(&v), "{v} drawn with labels off");
|
||||
}
|
||||
for cat in ["a", "b", "c"] {
|
||||
assert!(texts(&off).contains(&cat));
|
||||
assert!(texts(&on).contains(&cat));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_points_follow_workbook_theme() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.theme.colors[4] = 0x4F6D8A;
|
||||
wb.theme.colors[5] = 0x8AA1B8;
|
||||
let mut c = chart(ChartKind::Pie);
|
||||
c.series = vec![Series {
|
||||
name: None,
|
||||
categories: None,
|
||||
values: "{5,3}".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
|
||||
assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]);
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend, expected);
|
||||
let fills: Vec<Rgba> = render(&c, &d, 480.0, 288.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { fill, .. } => Some(*fill),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(fills, expected);
|
||||
for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] {
|
||||
c.kind = kind;
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend, expected, "{kind:?}");
|
||||
}
|
||||
// Past the 36-colour cycle the points keep following the workbook theme.
|
||||
c.kind = ChartKind::Pie;
|
||||
c.series[0].values = format!("{{{}}}", ["1"; 40].join(","));
|
||||
let d = resolve(&wb, 0, &c);
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend.len(), 40);
|
||||
assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37)));
|
||||
assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed {
|
|||
|
||||
/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00").
|
||||
pub fn parse_number_text(s: &str) -> Option<f64> {
|
||||
parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n)
|
||||
parse_number_text_in(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
/// Like [`parse_number_text`], with date text read in the given workbook date system.
|
||||
pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
parse_numeric(s.trim(), sys).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
|
|
|
|||
|
|
@ -224,13 +224,17 @@ impl Value {
|
|||
/// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses
|
||||
/// (including dates, times, percentages and currency), other text = `#VALUE!`.
|
||||
pub fn to_number(&self) -> Result<f64, CellError> {
|
||||
self.to_number_in(crate::DateSystem::D1900)
|
||||
}
|
||||
/// [`Value::to_number`] with date text read in the given workbook date system.
|
||||
pub fn to_number_in(&self, sys: crate::DateSystem) -> Result<f64, CellError> {
|
||||
match self {
|
||||
Value::Empty => Ok(0.0),
|
||||
Value::Number(n) => Ok(*n),
|
||||
Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
|
||||
Value::Text(t) => crate::parse::parse_number_text(t).ok_or(CellError::Value),
|
||||
Value::Text(t) => crate::parse::parse_number_text_in(t, sys).ok_or(CellError::Value),
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_number()).unwrap_or(Ok(0.0)),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_number_in(sys)).unwrap_or(Ok(0.0)),
|
||||
}
|
||||
}
|
||||
/// Coercion to text as `&` does it.
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
fn col_param(v: Option<&Json>, base: u32) -> Option<u32> {
|
||||
match v? {
|
||||
Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')),
|
||||
Json::Number(n) => n.as_u64().map(|o| base + o as u32),
|
||||
Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -500,7 +500,12 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
|
||||
}
|
||||
};
|
||||
let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?;
|
||||
let col = match col_param(p.get("column"), af.range.start.col) {
|
||||
Some(c) => c,
|
||||
// A number too large to be a column is outside the filter range, not a missing parameter.
|
||||
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
|
||||
None => return Err(bad("data.filterBy", "missing `column`")),
|
||||
};
|
||||
if col < af.range.start.col || col > af.range.end.col {
|
||||
return Err(bad("data.filterBy", "column outside the filter range"));
|
||||
}
|
||||
|
|
@ -1158,6 +1163,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
|||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
|
@ -1307,6 +1313,20 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
|
||||
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
|
||||
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
|
||||
}
|
||||
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
|
||||
assert_eq!(ok["hiddenRows"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
|
||||
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
|
|||
Ok(json!({"selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||
// A defined name or table?
|
||||
let d = s.doc()?;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
return ok();
|
||||
};
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
|
||||
let imported =
|
||||
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);
|
||||
|
|
@ -110,11 +111,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
}
|
||||
let d = s.doc_mut()?;
|
||||
// The document's own file keeps the format its content had when opened; any other path
|
||||
// goes by its extension.
|
||||
// goes by its extension. Relative paths to linked workbooks are rewritten for the new folder.
|
||||
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
|
||||
let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f));
|
||||
let wb = rebased.as_ref().unwrap_or(&d.wb);
|
||||
let bytes = match kind {
|
||||
Some(kind) => crate::io::save_bytes_as(&d.wb, &path, kind)?,
|
||||
None => crate::io::save_bytes(&d.wb, &path)?,
|
||||
Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?,
|
||||
None => crate::io::save_bytes(wb, &path)?,
|
||||
};
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Forecast Sheet",
|
||||
["Data", "Forecast"],
|
||||
None,
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
has_doc,
|
||||
forecast_sheet
|
||||
)]
|
||||
|
|
@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
|
||||
return Err(EngineError::Other("There are no values to forecast.".into()));
|
||||
}
|
||||
let end = match p.get("end") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(end) => Some(end),
|
||||
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
_ => None,
|
||||
let time_param = |key: &str| -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
Some(Json::Number(n)) => Ok(n.as_f64()),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(x) => Ok(Some(x)),
|
||||
_ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(_)) => Ok(None),
|
||||
Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
|
||||
}
|
||||
};
|
||||
let end = time_param("end")?;
|
||||
// Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
|
||||
// usual forecast after the history.
|
||||
let fit = match time_param("forecastStart")? {
|
||||
None => rows.len(),
|
||||
Some(start) => {
|
||||
let last_t = times.last().copied().unwrap_or(f64::NAN);
|
||||
let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
|
||||
if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
|
||||
rows.len()
|
||||
} else {
|
||||
let i = times
|
||||
.iter()
|
||||
.position(|t| tol(*t, start))
|
||||
.ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
|
||||
if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
|
||||
return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
};
|
||||
let cap = max_periods(rows.len());
|
||||
let periods = match end {
|
||||
|
|
@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
// Columns: A timeline, B values, C forecast, D/E bounds.
|
||||
let n = rows.len() as u32;
|
||||
let last = n + 1; // 1-based row of the last history point
|
||||
let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
|
||||
let end_row = last + periods; // 1-based row of the last forecast
|
||||
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
|
||||
let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
|
||||
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
|
||||
if let Some(a) = opts.aggregate {
|
||||
tail.push_str(&format!(",{a}"));
|
||||
|
|
@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
put(&mut sheet, r, 1, v.clone(), vstyle);
|
||||
}
|
||||
}
|
||||
// The last history point starts the forecast lines, so they join the values.
|
||||
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
|
||||
// The last point the model is fit to starts the forecast lines, so they join the values.
|
||||
let join = fit_last - 1; // 0-based row
|
||||
formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle);
|
||||
if opts.confidence.is_some() {
|
||||
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
|
||||
formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
|
||||
}
|
||||
let (tl, vl) = (hist(0), hist(1));
|
||||
let forecast_row = |sheet: &mut Sheet, r: u32| {
|
||||
formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
};
|
||||
// Hindcast: forecasts over the actuals from the start to the last history point.
|
||||
for r in (fit_last + 1)..=last {
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
for (k, t) in future.iter().enumerate() {
|
||||
let r = last + k as u32 + 1; // 1-based
|
||||
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
|
||||
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
if opts.statistics {
|
||||
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
|
||||
|
|
@ -407,7 +442,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
if let Ok(d) = s.doc_mut() {
|
||||
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
|
||||
}
|
||||
|
||||
/// Forecast rows needed to reach `end` (rounded up to whole steps).
|
||||
|
|
@ -545,4 +580,39 @@ mod tests {
|
|||
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_hindcasts_over_the_actuals() {
|
||||
let mut s = session_with_history(); // t = 1..16 in A2:A17
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
|
||||
assert_eq!(r["fitted"], 12);
|
||||
// Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
|
||||
assert_eq!(formula(&s, 0, "C13"), "B13");
|
||||
assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
|
||||
assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
// The exact seasonal series is recovered, and the actual is still shown next to it.
|
||||
let c14 = value(&s, 0, "C14").as_f64().unwrap();
|
||||
assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
|
||||
assert!(value(&s, 0, "B14").as_f64().is_some());
|
||||
// Start at the last point or one step later: as without forecastStart.
|
||||
for start in [json!(16), json!(17)] {
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_invalid_values_are_errors() {
|
||||
let mut s = session_with_history();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
// Blank or null behaves like no start; empty params unchanged.
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -963,6 +963,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
e.custom = true;
|
||||
}
|
||||
}
|
||||
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
|
@ -1002,9 +1003,7 @@ pub fn chars_to_points(c: f64) -> f64 {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn points_to_chars(px: f64) -> f64 {
|
||||
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
|
||||
}
|
||||
pub use gridcraft_model::points_to_chars;
|
||||
|
||||
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
|
||||
/// widths for the default font; the UI refines with real measurement when it autofits).
|
||||
|
|
@ -1079,6 +1078,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
}
|
||||
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
|
||||
}
|
||||
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,6 +157,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
cmd!(
|
||||
"link.open",
|
||||
"Open Hyperlink",
|
||||
[],
|
||||
None,
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
|
||||
has_doc,
|
||||
open_link
|
||||
),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
|
||||
s, p, true
|
||||
|
|
@ -1184,6 +1193,31 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
|
||||
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
|
||||
/// dialog; the interactive click asks for confirmation first, in the UI.
|
||||
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
|
||||
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
|
||||
};
|
||||
h.target.clone()
|
||||
}
|
||||
};
|
||||
match crate::links::classify(&target) {
|
||||
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
|
||||
crate::links::LinkKind::External(u) => {
|
||||
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
|
||||
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
|
||||
/// Page") is outside the workbook and saved as a relationship.
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,21 @@ fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
|||
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
||||
}
|
||||
|
||||
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
|
||||
fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
|
||||
let mut ext = 0.0;
|
||||
let mut col = c.col + 1;
|
||||
while col < MAX_COLS && ext < 20_000.0 {
|
||||
let nc = CellRef::new(c.row, col);
|
||||
if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross {
|
||||
break;
|
||||
}
|
||||
ext += sh.col_width(col);
|
||||
col += 1;
|
||||
}
|
||||
ext
|
||||
}
|
||||
|
||||
fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) {
|
||||
let (x, y, w, h) = rect;
|
||||
let sh = b.sh;
|
||||
|
|
@ -1110,8 +1125,11 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
|||
VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h,
|
||||
};
|
||||
// Overflow into empty neighbours (single-line text, not merged).
|
||||
let (mut clip_l, mut clip_r) = (x, x + w);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
|
||||
// Center Across Selection spans the empty Center Across cells to its right; the text stays within that run.
|
||||
let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0;
|
||||
let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 };
|
||||
let (mut clip_l, mut clip_r) = (x, x + w + across);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
|
|
@ -1183,7 +1201,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
|||
let lw = gridcraft_pdf::text_width(font, size, line);
|
||||
let lx = match h_align {
|
||||
HAlign::Right => x + w - pad - lw - indent,
|
||||
HAlign::Center => x + (w - lw) / 2.0,
|
||||
HAlign::Center => x + (w + across - lw) / 2.0,
|
||||
_ => x + pad + indent,
|
||||
};
|
||||
let ly = first_baseline + i as f32 * line_h;
|
||||
|
|
@ -2088,6 +2106,23 @@ mod tests {
|
|||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_spans_following_empty_center_across_cells() {
|
||||
let mut styles = StyleTable::default();
|
||||
let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() });
|
||||
let mut sh = Sheet::new("S");
|
||||
for col in 0..3 {
|
||||
sh.set_style(CellRef::new(0, col), across);
|
||||
}
|
||||
sh.set_value(CellRef::new(0, 0), Value::Text("Title".into()));
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2));
|
||||
sh.set_value(CellRef::new(0, 2), Value::Text("x".into()));
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1));
|
||||
let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))];
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_wrapped_text_prints_rotated() {
|
||||
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
|
||||
|
|
|
|||
|
|
@ -292,30 +292,65 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
|||
Ok(out)
|
||||
}
|
||||
|
||||
/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
|
||||
/// last line to the first so deleting one never shifts the next. A comma-separated list
|
||||
/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
|
||||
fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
|
||||
let key = if axis == Axis::Rows { "rows" } else { "cols" };
|
||||
let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
// Deleting past the last line only removes up to the end of the worksheet.
|
||||
let count = count.min(limit.saturating_sub(at)).max(1);
|
||||
vec![(at, at.saturating_add(count - 1))]
|
||||
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
t.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
|
||||
};
|
||||
let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
|
||||
spans.sort_unstable();
|
||||
let mut merged: Vec<(u32, u32)> = Vec::new();
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
|
||||
}
|
||||
|
||||
fn delete_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 };
|
||||
// Deleting past the last line only removes up to the end of the worksheet.
|
||||
let count = count.min(limit.saturating_sub(at)).max(1);
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
let areas = delete_areas(s, p, axis)?;
|
||||
for &(at, count) in &areas {
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
// Last area first, so the earlier ones keep their positions.
|
||||
for (at, count) in areas {
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ use gridcraft_numfmt::{Formatted, NumberFormat, format_value};
|
|||
/// Parsed number formats are cached by code.
|
||||
fn parsed(code: &str) -> NumberFormat {
|
||||
static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = m.get(code) {
|
||||
let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = lock().get(code) {
|
||||
return f.clone();
|
||||
}
|
||||
// Parse outside the lock, so a slow code never blocks the other threads' lookups.
|
||||
let f = NumberFormat::parse(code);
|
||||
let mut m = lock();
|
||||
if m.len() > 4096 {
|
||||
m.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
|||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
let 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.
|
||||
if !clean.is_empty()
|
||||
&& !clean.eq_ignore_ascii_case("History")
|
||||
&& let Some(sh) = wb.sheet_mut(0)
|
||||
{
|
||||
sh.name = clean;
|
||||
|
|
@ -178,17 +180,31 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
|||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`save_bytes`] for the file at `path`: relative paths to linked workbooks are rewritten for
|
||||
/// its folder, so they still name the same files.
|
||||
pub fn save_file_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
match source_dir(path).and_then(|f| gridcraft_xlsx::rebase_links(wb, &f)) {
|
||||
Some(rebased) => save_bytes(&rebased, path),
|
||||
None => save_bytes(wb, path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder of the file at `path`, which relative paths to linked workbooks are relative to.
|
||||
pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
|
||||
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
|
||||
// General's grid-width formatting rounds large identifiers to scientific notation.
|
||||
// Delimited files have no column width, so preserve the whole numeric value instead.
|
||||
|
|
@ -201,6 +217,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
|||
crate::display::value_text(wb, sh, c)
|
||||
}
|
||||
})
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
|
|
@ -486,6 +503,17 @@ mod tests {
|
|||
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_named_history_does_not_get_the_reserved_sheet_name() {
|
||||
for file in ["History.csv", "hIsToRy.csv", "history.tsv"] {
|
||||
let (wb, _) = open_bytes(file, b"1\n").unwrap();
|
||||
let name = wb.sheets[0].name.clone();
|
||||
assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}");
|
||||
}
|
||||
let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap();
|
||||
assert_eq!(wb.sheets[0].name, "Sales");
|
||||
}
|
||||
|
||||
/// A save that can't be put in place (here a folder already has the workbook's name, as
|
||||
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod cmd;
|
|||
pub mod display;
|
||||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod links;
|
||||
pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file
|
||||
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
|
||||
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
|
||||
//! the like are refused. Everything without a scheme stays an in-workbook reference.
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
|
||||
/// What a link target is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LinkKind {
|
||||
/// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`).
|
||||
Internal(String),
|
||||
/// An address to hand to the system, trimmed: `http`, `https` or `mailto` only.
|
||||
External(String),
|
||||
/// Refused, with the reason to show.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
|
||||
|
||||
/// Classifies a link target. Never panics; the single place that decides what may be opened.
|
||||
pub fn classify(target: &str) -> LinkKind {
|
||||
let t = target.trim();
|
||||
if t.chars().any(char::is_control) {
|
||||
return LinkKind::Refused("The link contains control characters.".into());
|
||||
}
|
||||
if t.starts_with("\\\\") || t.starts_with("//") {
|
||||
return LinkKind::Refused("Links to network locations aren't opened.".into());
|
||||
}
|
||||
if RangeRef::parse(t).is_some() {
|
||||
return LinkKind::Internal(t.to_string());
|
||||
}
|
||||
let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
|
||||
let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
if !scheme_like {
|
||||
return LinkKind::Internal(t.to_string());
|
||||
}
|
||||
let lower = scheme.to_ascii_lowercase();
|
||||
if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
|
||||
LinkKind::External(t.to_string())
|
||||
} else {
|
||||
LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_http_https_and_mailto_leave_the_workbook() {
|
||||
for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] {
|
||||
assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
|
||||
}
|
||||
assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
|
||||
for bad in [
|
||||
"javascript:alert(1)",
|
||||
"JaVaScRiPt:alert(1)",
|
||||
"file:///etc/passwd",
|
||||
"smb://host/share",
|
||||
"ms-excel:ofe|u|http://x",
|
||||
"\\\\host\\share\\x.exe",
|
||||
"//host/share",
|
||||
"C:\\Windows\\calc.exe",
|
||||
"\tjavascript:1",
|
||||
"java\nscript:1",
|
||||
"\u{1}http://a.example",
|
||||
"http:",
|
||||
"mailto:",
|
||||
"x-custom://a",
|
||||
] {
|
||||
assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_stay_internal() {
|
||||
for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {
|
||||
assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -560,6 +560,28 @@ fn insert_delete_rows_adjust() {
|
|||
assert_eq!(v(&s, "A1"), Value::Number(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap();
|
||||
// Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based).
|
||||
let sel = &mut s.doc_mut().unwrap().selection;
|
||||
sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)];
|
||||
sel.active = CellRef::new(4, 0);
|
||||
s.doc_mut().unwrap().undo.clear();
|
||||
s.execute("home.deleteRows", json!({})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::<Vec<_>>(), (1..=6).map(|n| Value::Number(n as f64)).collect::<Vec<_>>());
|
||||
assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows");
|
||||
// The explicit list form behaves the same, for columns too.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_reorders_without_overwriting_and_undoes_once() {
|
||||
let mut s = s();
|
||||
|
|
@ -1058,6 +1080,36 @@ fn tables_and_structured_refs() {
|
|||
assert_eq!(v(&s, "B4"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_refs_to_columns_with_edge_spaces() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap();
|
||||
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
|
||||
assert_eq!(v(&s, "B4"), Value::Number(5.0));
|
||||
for (cell, input) in [
|
||||
("F1", "=SUM(Table1[Qty ])"),
|
||||
("F2", "=Table1[[#Totals],[Qty ]]"),
|
||||
("F3", "=SUBTOTAL(109,Table1[Qty ])"),
|
||||
("F4", "=SUM(Table1[[Qty ]])"),
|
||||
("F5", "=SUM(Table1[Qty])"),
|
||||
("C2", "=Table1[@[Qty ]]*10"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let n = Value::Number;
|
||||
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]);
|
||||
assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref));
|
||||
assert_eq!(v(&s, "C2"), n(20.0));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
|
||||
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
|
||||
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rename_keeps_a_defined_name_with_the_same_name() {
|
||||
let mut s = s();
|
||||
|
|
@ -1590,6 +1642,82 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
|
|||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
|
||||
fn linked_workbook(link: &str) -> Vec<u8> {
|
||||
use std::io::{Cursor, Write};
|
||||
const NS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/externalLinks/externalLink1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/></Types>"#.to_string();
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>"#
|
||||
);
|
||||
let book = format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId2"/></externalReferences></workbook>"#
|
||||
);
|
||||
let book_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="{NS_REL}/externalLink" Target="externalLinks/externalLink1.xml"/></Relationships>"#
|
||||
);
|
||||
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="B1"><f>[1]Sheet1!A1</f><v>10</v></c></row></sheetData></worksheet>"#.to_string();
|
||||
let link_part = format!(
|
||||
r#"<externalLink xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><externalBook r:id="rId1"><sheetNames><sheetName val="Sheet1"/></sheetNames><sheetDataSet><sheetData sheetId="0"><row r="1"><cell r="A1"><v>10</v></cell></row></sheetData></sheetDataSet></externalBook></externalLink>"#
|
||||
);
|
||||
let link_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/externalLinkPath" Target="{link}" TargetMode="External"/></Relationships>"#
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("[Content_Types].xml", types),
|
||||
("_rels/.rels", rels),
|
||||
("xl/workbook.xml", book),
|
||||
("xl/_rels/workbook.xml.rels", book_rels),
|
||||
("xl/worksheets/sheet1.xml", sheet),
|
||||
("xl/externalLinks/externalLink1.xml", link_part),
|
||||
("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The target of the linked workbook in a saved file.
|
||||
fn link_target(path: &std::path::Path) -> String {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let mut rels = String::new();
|
||||
zip.by_name("xl/externalLinks/_rels/externalLink1.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
|
||||
let (_, after) = rels.split_once("Target=\"").unwrap();
|
||||
after.split_once('"').unwrap().0.to_string()
|
||||
}
|
||||
|
||||
/// Save As into another folder keeps the link on the same file, as Excel does: the relative path
|
||||
/// is rewritten from the new folder (#801).
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() {
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id()));
|
||||
let b = a.join("b");
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let dest = a.join("Dest.xlsx");
|
||||
std::fs::write(&dest, linked_workbook("Source.xlsx")).unwrap();
|
||||
let mut s = s();
|
||||
s.execute("file.open", json!({"path": dest})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": b.join("Dest.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&b.join("Dest.xlsx")), "../Source.xlsx");
|
||||
// Saved again into the folder it was read from, the path is the one it was read with.
|
||||
s.execute("file.saveAs", json!({"path": a.join("Copy.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&a.join("Copy.xlsx")), "Source.xlsx");
|
||||
// Read from a subfolder and saved into its parent, the path goes down into the subfolder.
|
||||
let deep = b.join("Deep.xlsx");
|
||||
std::fs::write(&deep, linked_workbook("Source.xlsx")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"path": deep})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": a.join("Deep.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&a.join("Deep.xlsx")), "b/Source.xlsx");
|
||||
std::fs::remove_dir_all(a).unwrap();
|
||||
}
|
||||
|
||||
/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the
|
||||
/// document's file.
|
||||
#[test]
|
||||
|
|
@ -3013,6 +3141,38 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
|
|||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_open_refuses_every_scheme_but_http_https_and_mailto() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "file:///etc/passwd"})).unwrap();
|
||||
s.take_ui_requests();
|
||||
for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {
|
||||
assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}");
|
||||
}
|
||||
assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]);
|
||||
s.execute("link.open", json!({"url": "B2"})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(9.0));
|
||||
// A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL).
|
||||
s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(15.0));
|
||||
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(9.0));
|
||||
s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_validation_copies_the_rule() {
|
||||
let mut s = s();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as
|
||||
//! big as the whole rectangle between them. A refused export is an error and writes nothing.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn two_far_cells() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() {
|
||||
let mut s = two_far_cells();
|
||||
for format in ["csv", "tsv"] {
|
||||
let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string();
|
||||
assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}");
|
||||
}
|
||||
// Save As and Export write no file, and the document stays as it was.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.csv");
|
||||
let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string();
|
||||
assert!(err.contains("over the limit"), "{err}");
|
||||
assert!(!path.exists(), "nothing is written for a refused export");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
// Other formats are unaffected, and so is a small export from the same session.
|
||||
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_below_the_cap_keep_their_positions() {
|
||||
for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::cmd::draw::{ink_points, ink_width};
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::{Shape, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
fn workbook() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session
|
||||
}
|
||||
|
||||
fn reopen(session: &mut Session) -> Session {
|
||||
let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
reopened
|
||||
}
|
||||
|
||||
fn shapes(session: &Session) -> &[Shape] {
|
||||
&session.doc().unwrap().wb.active().unwrap().shapes
|
||||
}
|
||||
|
||||
fn assert_anchor(actual: &Shape, expected: &Shape) {
|
||||
assert_eq!(actual.anchor.cell, expected.anchor.cell);
|
||||
assert_eq!(actual.anchor.mode, expected.anchor.mode);
|
||||
for (name, actual, expected) in [
|
||||
("dx", actual.anchor.dx, expected.anchor.dx),
|
||||
("dy", actual.anchor.dy, expected.anchor.dy),
|
||||
("width", actual.anchor.width, expected.anchor.width),
|
||||
("height", actual.anchor.height, expected.anchor.height),
|
||||
] {
|
||||
assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_ink(actual: &Shape, expected: &Shape) {
|
||||
assert_eq!(actual.kind, ShapeKind::Ink);
|
||||
let actual_points = ink_points(actual);
|
||||
let expected_points = ink_points(expected);
|
||||
assert_eq!(actual_points.len(), expected_points.len());
|
||||
for (actual, expected) in actual_points.iter().zip(&expected_points) {
|
||||
for axis in 0..2 {
|
||||
// DrawingML quantizes coordinates to integer EMUs (9,525 per model unit).
|
||||
assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(actual.line, expected.line);
|
||||
assert_eq!(ink_width(actual), ink_width(expected));
|
||||
assert_anchor(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() {
|
||||
let mut session = workbook();
|
||||
session
|
||||
.execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3}))
|
||||
.unwrap();
|
||||
session
|
||||
.execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7}))
|
||||
.unwrap();
|
||||
let expected = shapes(&session).to_vec();
|
||||
assert_eq!(expected.len(), 2);
|
||||
|
||||
for pass in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_eq!(shapes(&session).len(), expected.len());
|
||||
for (actual, expected) in shapes(&session).iter().zip(&expected) {
|
||||
assert_ink(actual, expected);
|
||||
}
|
||||
if pass == 0 {
|
||||
session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_ink_can_still_be_converted_to_a_shape() {
|
||||
for (points, expected_kind, expected_name) in [
|
||||
(vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"),
|
||||
(vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"),
|
||||
] {
|
||||
let mut session = workbook();
|
||||
session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap();
|
||||
let original = shapes(&session)[0].clone();
|
||||
session = reopen(&mut session);
|
||||
let id = shapes(&session)[0].id;
|
||||
let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap();
|
||||
assert_eq!(converted["kind"], expected_name);
|
||||
let shape = &shapes(&session)[0];
|
||||
assert_eq!(shape.kind, expected_kind);
|
||||
assert_eq!(shape.line, original.line);
|
||||
assert!(shape.text.is_empty());
|
||||
assert_anchor(shape, &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_shape_text_is_not_mistaken_for_ink_points() {
|
||||
let mut session = workbook();
|
||||
session
|
||||
.execute(
|
||||
"insert.shape",
|
||||
json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}),
|
||||
)
|
||||
.unwrap();
|
||||
let original = shapes(&session)[0].clone();
|
||||
for _ in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_eq!(shapes(&session).len(), 1);
|
||||
let shape = &shapes(&session)[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Rectangle);
|
||||
assert_eq!(shape.text, original.text);
|
||||
assert_eq!(shape.fill, original.fill);
|
||||
assert_eq!(shape.line, original.line);
|
||||
assert_anchor(shape, &original);
|
||||
assert!(session.execute("draw.inkToShape", json!({})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() {
|
||||
for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] {
|
||||
let mut session = workbook();
|
||||
session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap();
|
||||
let expected = shapes(&session)[0].clone();
|
||||
for _ in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_ink(&shapes(&session)[0], &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr {
|
|||
}
|
||||
Expr::Ref(r)
|
||||
}
|
||||
// Sheet-qualified defined name `Old!MyName` is stored as one string.
|
||||
// Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser
|
||||
// stores it (the printer adds the quotes).
|
||||
Expr::Name(n) => match n.split_at_checked(old.len()) {
|
||||
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => {
|
||||
Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new)))
|
||||
}
|
||||
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")),
|
||||
_ => Expr::Name(n),
|
||||
},
|
||||
other => other,
|
||||
|
|
|
|||
|
|
@ -362,6 +362,10 @@ impl Parser {
|
|||
if w.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Err(self.err(format!("unexpected `{w}`")));
|
||||
}
|
||||
// A function passed by name is saved as `_xleta.SUM`.
|
||||
if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) {
|
||||
return Ok(Expr::Name(f.to_ascii_uppercase()));
|
||||
}
|
||||
Ok(Expr::Name(w.to_string()))
|
||||
}
|
||||
|
||||
|
|
@ -422,6 +426,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr {
|
|||
pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
|
||||
let mut specifiers = Vec::new();
|
||||
let mut cols: Vec<String> = Vec::new();
|
||||
// Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the
|
||||
// untrimmed body is what a lone column is read from.
|
||||
let raw = body;
|
||||
let body = body.trim();
|
||||
let unescape = |s: &str| {
|
||||
let mut out = String::new();
|
||||
|
|
@ -473,7 +480,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
|
|||
} else if let Some(i) = item(body) {
|
||||
specifiers.push(i);
|
||||
} else if !body.is_empty() {
|
||||
cols.push(column(body)?);
|
||||
cols.push(column(raw)?);
|
||||
}
|
||||
} else {
|
||||
// [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1]
|
||||
|
|
|
|||
|
|
@ -119,7 +119,12 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
|||
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
|
||||
}
|
||||
(Some(c), None) if s.specifiers.is_empty() => {
|
||||
let _ = write!(out, "[{}]", esc(c));
|
||||
// Excel writes a name with spaces at its edges in the double-bracket form.
|
||||
if c.trim() == c {
|
||||
let _ = write!(out, "[{}]", esc(c));
|
||||
} else {
|
||||
let _ = write!(out, "[[{}]]", esc(c));
|
||||
}
|
||||
}
|
||||
(Some(c), None) if this_row => {
|
||||
if simple_col(c) {
|
||||
|
|
@ -188,7 +193,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
|||
Some(at) => s.push_str(&reference_r1c1(r, at)),
|
||||
None => s.push_str(&reference_a1(r)),
|
||||
},
|
||||
Expr::Name(n) => s.push_str(n),
|
||||
Expr::Name(n) => match n.rsplit_once('!') {
|
||||
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
|
||||
Some((sheet, name)) => {
|
||||
s.push_str("e_sheet(sheet));
|
||||
s.push('!');
|
||||
s.push_str(name);
|
||||
}
|
||||
_ => s.push_str(n),
|
||||
},
|
||||
Expr::Struct(st) => s.push_str(&struct_ref(st)),
|
||||
Expr::Unary(op, x) => match op {
|
||||
UnOp::Neg => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@ fn rt(src: &str) -> String {
|
|||
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sheet_qualified_name_keeps_quotes() {
|
||||
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
|
||||
assert_eq!(rt("'Data'!Total"), "Data!Total");
|
||||
assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_with_deleted_operand_reparses() {
|
||||
// Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back.
|
||||
|
|
@ -64,6 +71,7 @@ fn normalises() {
|
|||
assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)");
|
||||
assert_eq!(rt("= 1 + 2"), "1+2");
|
||||
assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)");
|
||||
assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)");
|
||||
assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")");
|
||||
assert_eq!(rt("true"), "TRUE");
|
||||
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
|
||||
|
|
@ -200,6 +208,10 @@ fn rename_sheet_updates_qualified_names() {
|
|||
assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName");
|
||||
let e = parse("Data!MyName").unwrap();
|
||||
assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName");
|
||||
// Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches.
|
||||
let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's");
|
||||
assert_eq!(e, Expr::Name("It's!MyName".into()));
|
||||
assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -265,6 +277,20 @@ fn unbracketed_this_row_column() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_column_keeps_edge_spaces() {
|
||||
// The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`.
|
||||
let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() };
|
||||
assert_eq!(s.col_start.as_deref(), Some("Qty "));
|
||||
let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() };
|
||||
assert_eq!(s.col_start.as_deref(), Some(" Qty"));
|
||||
// Excel writes such a column in the double-bracket form.
|
||||
assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]");
|
||||
for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] {
|
||||
assert_eq!(rt(f), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_references() {
|
||||
for f in [
|
||||
|
|
|
|||
|
|
@ -138,9 +138,10 @@ impl Criterion {
|
|||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells.
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match &v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, "")),
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
|
|
@ -233,19 +234,25 @@ fn tokenize(pattern: &str) -> Vec<Tok> {
|
|||
while let Some(c) = it.next() {
|
||||
match c {
|
||||
'~' => match it.next() {
|
||||
Some(n) => out.extend(n.to_lowercase().map(Tok::Char)),
|
||||
Some(n) => out.extend(std::iter::once(lower_char(n)).map(Tok::Char)),
|
||||
None => out.push(Tok::Char('~')),
|
||||
},
|
||||
'*' => out.push(Tok::Star),
|
||||
'?' => out.push(Tok::Any),
|
||||
c => out.extend(c.to_lowercase().map(Tok::Char)),
|
||||
c => out.extend(std::iter::once(lower_char(c)).map(Tok::Char)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lowercase one char to exactly one char, so positions in the lowered text match the original
|
||||
/// (U+0130 `İ` folds to `i`, like `fold` in text.rs).
|
||||
fn lower_char(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
fn lower_chars(s: &str) -> Vec<char> {
|
||||
s.chars().flat_map(char::to_lowercase).collect()
|
||||
s.chars().map(lower_char).collect()
|
||||
}
|
||||
|
||||
/// Whether the pattern contains an unescaped wildcard.
|
||||
|
|
@ -355,6 +362,9 @@ mod tests {
|
|||
assert!(m("<>", Value::from("a")));
|
||||
assert!(!m("<>", Value::Empty));
|
||||
assert!(m("TRUE", Value::Bool(true)));
|
||||
// A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0).
|
||||
assert!(!m("<=", Value::from("")));
|
||||
assert!(!m(">=", Value::from("")));
|
||||
assert!(m("<b", Value::from("apple")));
|
||||
assert!(!m("<b", Value::Number(1.0)));
|
||||
assert!(m("#N/A", Value::Error(CellError::NA)));
|
||||
|
|
@ -382,6 +392,8 @@ mod tests {
|
|||
assert_eq!(wildcard_find("b?d", "abxxd bcd", 0), Some(6));
|
||||
assert_eq!(wildcard_find("c*", "abcd", 0), Some(2));
|
||||
assert_eq!(wildcard_find("z", "abcd", 0), None);
|
||||
assert_eq!(wildcard_find("b", "a\u{130}bc", 0), Some(2));
|
||||
assert!(wildcard_match("i*", "\u{130}stanbul"));
|
||||
assert!(has_wildcards("a*"));
|
||||
assert!(!has_wildcards("abc"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//! The database and the criteria are arrays whose first row holds column labels. Each criteria
|
||||
//! row is a set of conditions that must all hold (AND); a record matches when any criteria row
|
||||
//! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that
|
||||
//! begin with the text, as in Excel's advanced filter.
|
||||
//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A
|
||||
//! formula under a label that names no field is a computed criterion, which the host evaluates
|
||||
//! for each record ([`Ctx::computed_criterion`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
|
|
@ -49,7 +51,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
|
|||
fn cell_criterion(v: &Value) -> Option<Criterion> {
|
||||
match v {
|
||||
Value::Empty => None,
|
||||
Value::Text(t) if t.is_empty() => None,
|
||||
Value::Text(t) => {
|
||||
let s: &str = t;
|
||||
let has_op = s.starts_with(['=', '<', '>']);
|
||||
|
|
@ -65,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option<Criterion> {
|
|||
}
|
||||
|
||||
/// Rows (1-based within the database, excluding the header) of matching records.
|
||||
fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
|
||||
// Per criteria row: list of (database column or None when the label is unknown, criterion).
|
||||
let mut rows: Vec<Vec<(Option<usize>, Criterion)>> = Vec::new();
|
||||
fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec<usize> {
|
||||
// Per criteria row: its index and a list of (criteria column, database column or None when
|
||||
// the label is unknown, criterion).
|
||||
let mut rows: Vec<(usize, Vec<(usize, Option<usize>, Criterion)>)> = Vec::new();
|
||||
for r in 1..crit.rows {
|
||||
let mut conds = Vec::new();
|
||||
for c in 0..crit.cols {
|
||||
|
|
@ -78,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
|
|||
Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)),
|
||||
_ => None,
|
||||
};
|
||||
conds.push((col, cr));
|
||||
conds.push((c, col, cr));
|
||||
}
|
||||
rows.push(conds);
|
||||
rows.push((r, conds));
|
||||
}
|
||||
let all_match = rows.is_empty();
|
||||
(1..db.rows)
|
||||
.filter(|&r| {
|
||||
.filter(|&rec| {
|
||||
all_match
|
||||
|| rows.iter().any(|conds| {
|
||||
conds.iter().all(|(col, cr)| match col {
|
||||
Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)),
|
||||
None => false,
|
||||
|| rows.iter().any(|(r, conds)| {
|
||||
conds.iter().all(|(c, col, cr)| match col {
|
||||
Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)),
|
||||
// Under a label that names no field, only a formula (a computed
|
||||
// criterion) can hold.
|
||||
None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -102,7 +106,7 @@ struct Selection<'a> {
|
|||
records: Vec<usize>,
|
||||
}
|
||||
|
||||
fn select(a: &[Arg]) -> R<Selection<'_>> {
|
||||
fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R<Selection<'a>> {
|
||||
let dbv = &a.first().ok_or(CellError::Value)?.value;
|
||||
let critv = &a.get(2).ok_or(CellError::Value)?.value;
|
||||
if let Value::Error(e) = dbv {
|
||||
|
|
@ -114,31 +118,31 @@ fn select(a: &[Arg]) -> R<Selection<'_>> {
|
|||
let db = as_array(dbv);
|
||||
let crit = as_array(critv);
|
||||
let field = field_index(&db, a)?;
|
||||
let records = matching_records(&db, &crit);
|
||||
let records = matching_records(&db, &crit, ctx);
|
||||
Ok(Selection { db, field, records })
|
||||
}
|
||||
|
||||
/// Numbers of the field among matching records (field required).
|
||||
fn field_numbers(a: &[Arg]) -> R<Vec<f64>> {
|
||||
let s = select(a)?;
|
||||
fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R<Vec<f64>> {
|
||||
let s = select(a, c)?;
|
||||
let f = s.field.ok_or(CellError::Value)?;
|
||||
Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect())
|
||||
}
|
||||
|
||||
fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(field_numbers(a)?.iter().sum())
|
||||
fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(field_numbers(a, c)?.iter().sum())
|
||||
}
|
||||
|
||||
fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
if v.is_empty() {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
num_val(v.iter().sum::<f64>() / v.len() as f64)
|
||||
}
|
||||
|
||||
fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let n = match s.field {
|
||||
Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(),
|
||||
None => s.records.len(),
|
||||
|
|
@ -146,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
num_val(n as f64)
|
||||
}
|
||||
|
||||
fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let n = match s.field {
|
||||
Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(),
|
||||
None => s.records.len(),
|
||||
|
|
@ -155,28 +159,31 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
num_val(n as f64)
|
||||
}
|
||||
|
||||
fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dget(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let f = s.field.ok_or(CellError::Value)?;
|
||||
match s.records.as_slice() {
|
||||
[] => Err(CellError::Value),
|
||||
[r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)),
|
||||
// Records whose field is blank (or "") don't count, as in Excel.
|
||||
let mut found =
|
||||
s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty()));
|
||||
match (found.next(), found.next()) {
|
||||
(None, _) => Err(CellError::Value),
|
||||
(Some(v), None) => Ok(v.clone()),
|
||||
_ => Err(CellError::Num),
|
||||
}
|
||||
}
|
||||
|
||||
fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
if v.is_empty() {
|
||||
return num_val(0.0);
|
||||
}
|
||||
|
|
@ -194,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R<f64> {
|
|||
Ok(ss / if sample { n - 1.0 } else { n })
|
||||
}
|
||||
|
||||
fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, true)?)
|
||||
fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, true)?)
|
||||
}
|
||||
|
||||
fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, false)?)
|
||||
fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, false)?)
|
||||
}
|
||||
|
||||
fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, true)?.sqrt())
|
||||
fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, true)?.sqrt())
|
||||
}
|
||||
|
||||
fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, false)?.sqrt())
|
||||
fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, false)?.sqrt())
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
|
|
@ -371,4 +378,69 @@ mod tests {
|
|||
is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref);
|
||||
close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86).
|
||||
#[test]
|
||||
fn empty_string_criterion() {
|
||||
let db = arr(vec![
|
||||
vec![tv("Index"), tv("Value")],
|
||||
vec![tv("a"), tv("=")],
|
||||
vec![tv("b"), Value::Empty],
|
||||
vec![tv("c"), tv("")],
|
||||
vec![tv("d"), tv(" ")],
|
||||
vec![tv("f"), Value::Error(CellError::Value)],
|
||||
vec![tv("h"), tv("abc")],
|
||||
vec![tv("i"), nv(0.0)],
|
||||
]);
|
||||
let get = |index: &str| {
|
||||
let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)])
|
||||
};
|
||||
// Like any plain text, "" is a "begins with" pattern: it matches every text value.
|
||||
is_text(get("c"), "c");
|
||||
is_text(get("h"), "h");
|
||||
is_err(get("b"), CellError::Value);
|
||||
is_err(get("i"), CellError::Value);
|
||||
is_err(get("f"), CellError::Value);
|
||||
let all = arr(vec![vec![tv("Value")], vec![tv("")]]);
|
||||
close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0);
|
||||
|
||||
// Under a label that names no field, "" is a condition that never holds.
|
||||
let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]);
|
||||
let get = |label: Value, value: Value| {
|
||||
let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)])
|
||||
};
|
||||
is_text(get(Value::Empty, Value::Empty), "a");
|
||||
is_text(get(tv(""), Value::Empty), "a");
|
||||
is_err(get(Value::Empty, tv("")), CellError::Value);
|
||||
is_err(get(tv(""), tv("")), CellError::Value);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119).
|
||||
#[test]
|
||||
fn dget_skips_empty_fields() {
|
||||
let db = arr(vec![
|
||||
vec![tv("One"), tv("Two")],
|
||||
vec![tv("a"), Value::Empty],
|
||||
vec![tv("b"), tv(" ")],
|
||||
vec![tv("c"), tv("")],
|
||||
vec![tv("d"), Value::Error(CellError::NA)],
|
||||
vec![tv("j"), Value::Empty],
|
||||
vec![tv("j"), tv("Text")],
|
||||
vec![tv("j"), Value::Empty],
|
||||
vec![tv("k"), Value::Empty],
|
||||
vec![tv("k"), tv("")],
|
||||
]);
|
||||
let get = |one: &str| {
|
||||
let crit = arr(vec![vec![tv("One")], vec![tv(one)]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)])
|
||||
};
|
||||
is_err(get("a"), CellError::Value);
|
||||
is_text(get("b"), " ");
|
||||
is_err(get("c"), CellError::Value);
|
||||
is_err(get("d"), CellError::NA);
|
||||
is_text(get("j"), "Text");
|
||||
is_err(get("k"), CellError::Value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Date and time functions.
|
||||
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd, serial_in_system};
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd, serial_in_system};
|
||||
use gridcraft_core::parse::parse_input;
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
|
|
@ -164,7 +164,9 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
|||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
|
||||
// Excel keeps serial 0's day 0 and gives February 1900 its real 28 days here, though the
|
||||
// sheet's calendar has a 29 February 1900 (serial 60).
|
||||
let dd = (d.day as i64).min(days_in_month(y as i32, mo as u32) as i64);
|
||||
num_val(ymd_serial(sys, y, mo, dd)? as f64)
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +179,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
|||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
|
||||
// Like EDATE, February 1900 ends on the 28th (serial 59).
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
|
||||
}
|
||||
|
||||
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
|
|
@ -680,17 +683,35 @@ mod tests {
|
|||
close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0);
|
||||
close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0);
|
||||
is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value);
|
||||
// EDATE and EOMONTH land on it as the end of February 1900.
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0);
|
||||
// EDATE and EOMONTH read it as 29 February, but end February 1900 on the 28th.
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0));
|
||||
close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edate_eomonth_early_1900() {
|
||||
// Excel 365: serial 0 is 0 January 1900 and February 1900 has 28 days here.
|
||||
close(ev("EDATE", vec![n(0.0), n(0.0)]), 0.0);
|
||||
close(ev("EDATE", vec![n(0.0), n(1.0)]), 31.0);
|
||||
close(ev("EDATE", vec![n(0.0), n(12.0)]), 366.0);
|
||||
close(ev("EOMONTH", vec![n(0.0), n(1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(31.0), n(1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(59.0), n(0.0)]), 59.0);
|
||||
// Unchanged, and the same as Excel.
|
||||
close(ev("EOMONTH", vec![n(0.0), n(0.0)]), 31.0);
|
||||
is_err(ev("EOMONTH", vec![n(0.0), n(-1.0)]), CellError::Num);
|
||||
close(ev("EDATE", vec![n(59.0), n(1.0)]), 88.0);
|
||||
close(ev("EDATE", vec![n(60.0), n(1.0)]), 89.0);
|
||||
close(ev("EDATE", vec![n(61.0), n(-1.0)]), 32.0);
|
||||
close(ev("EOMONTH", vec![n(31.0), n(0.0)]), 31.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datedif_units() {
|
||||
let s = n(d(2001.0, 6.0, 1.0));
|
||||
|
|
|
|||
|
|
@ -321,7 +321,8 @@ fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
neg += x / (1.0 + fr).powi(i as i32);
|
||||
}
|
||||
}
|
||||
if pos == 0.0 || neg == 0.0 {
|
||||
// No positive flow is not an error in Excel: the formula below gives -1.
|
||||
if neg == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0;
|
||||
|
|
@ -1813,6 +1814,8 @@ mod tests {
|
|||
close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6);
|
||||
close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7);
|
||||
is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0);
|
||||
// Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing.
|
||||
close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12);
|
||||
close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089);
|
||||
close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584);
|
||||
is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
use gridcraft_core::{Array, CellError, DateSystem, Value};
|
||||
|
||||
/// Builds a [`FnSpec`] whose implementation returns `Result<Value, CellError>`.
|
||||
///
|
||||
|
|
@ -105,6 +105,12 @@ pub trait Ctx {
|
|||
fn now_serial(&self) -> f64;
|
||||
/// Uniform in [0, 1).
|
||||
fn random(&mut self) -> f64;
|
||||
/// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds
|
||||
/// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the
|
||||
/// criteria didn't come from a reference.
|
||||
fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Text functions.
|
||||
|
||||
use gridcraft_core::parse::parse_number_text;
|
||||
use gridcraft_core::parse::parse_number_text_in;
|
||||
use gridcraft_core::{CellError, Value, number_to_text};
|
||||
|
||||
use crate::criteria::wildcard_find;
|
||||
|
|
@ -295,17 +295,17 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
|
||||
/// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed:
|
||||
/// `VALUE("")` is `#VALUE!`).
|
||||
fn value(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn value(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
match scalar(arg(a, 0)?) {
|
||||
Value::Number(n) => num_val(n),
|
||||
Value::Empty => num_val(0.0),
|
||||
Value::Error(e) => Err(e),
|
||||
Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val),
|
||||
Value::Text(t) => parse_number_text_in(&t, c.date_system()).ok_or(CellError::Value).and_then(num_val),
|
||||
_ => Err(CellError::Value),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = text(a, 0)?;
|
||||
let dec = if a.len() > 1 { text(a, 1)?.chars().next().ok_or(CellError::Value)? } else { '.' };
|
||||
let grp = if a.len() > 2 { text(a, 2)?.chars().next().ok_or(CellError::Value)? } else { ',' };
|
||||
|
|
@ -338,7 +338,7 @@ fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
|||
out.push(c);
|
||||
}
|
||||
}
|
||||
let mut n = parse_number_text(&out).ok_or(CellError::Value)?;
|
||||
let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?;
|
||||
for _ in 0..pct {
|
||||
n /= 100.0;
|
||||
}
|
||||
|
|
@ -845,6 +845,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
|||
fixed
|
||||
),
|
||||
f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar),
|
||||
f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar),
|
||||
f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext),
|
||||
f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn),
|
||||
f!(
|
||||
|
|
@ -1036,6 +1037,11 @@ mod tests {
|
|||
close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0);
|
||||
is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value);
|
||||
is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value);
|
||||
close(ev("SEARCH", vec![t("b"), t("\u{130}b")]), 2.0);
|
||||
close(ev("SEARCH", vec![t("B"), t("\u{130}b")]), 2.0);
|
||||
close(ev("SEARCH", vec![t("b?"), t("a\u{130}bc")]), 3.0);
|
||||
close(ev("SEARCH", vec![t("i"), t("a\u{130}bc")]), 2.0);
|
||||
is_err(ev("SEARCH", vec![t("b"), t("a\u{130}bc"), n(4.0)]), CellError::Value);
|
||||
for f in ["FIND", "SEARCH"] {
|
||||
close(ev(f, vec![t(""), t("abc"), n(3.0)]), 3.0);
|
||||
is_err(ev(f, vec![t(""), t("abc"), n(4.0)]), CellError::Value);
|
||||
|
|
@ -1099,6 +1105,8 @@ mod tests {
|
|||
is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57");
|
||||
is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
|
||||
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
|
||||
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||
is_text(ev("T", vec![t("x")]), "x");
|
||||
is_text(ev("T", vec![n(1.0)]), "");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ pub mod workbook;
|
|||
pub use cell::{Cell, CellPicture, Formula};
|
||||
pub use features::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sheet::Visibility;
|
||||
|
||||
/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part)
|
||||
/// to a kept part or to an external target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
|
@ -43,6 +45,19 @@ pub struct PreservedXml {
|
|||
pub xml: String,
|
||||
}
|
||||
|
||||
/// A chart sheet (a sheet that holds one chart and no cells) kept as it was in the source file. Its
|
||||
/// part, drawing, chart, style and colour parts are in [`Preserved::parts`]; the workbook
|
||||
/// relationship to it is in [`Preserved::rels`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PreservedSheet {
|
||||
pub name: String,
|
||||
pub visibility: Visibility,
|
||||
/// The id of the workbook relationship to the chart sheet part, in the source file.
|
||||
pub rel_id: String,
|
||||
/// How many worksheets come before it: its position among the model's sheets.
|
||||
pub before: usize,
|
||||
}
|
||||
|
||||
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
|
||||
/// or one sheet).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
|
@ -58,11 +73,18 @@ pub struct Preserved {
|
|||
/// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE
|
||||
/// objects). Its own relationships point into `parts`.
|
||||
pub vml: Option<PreservedPart>,
|
||||
/// Chart sheets, in file order (workbook owner only).
|
||||
pub sheets: Vec<PreservedSheet>,
|
||||
}
|
||||
|
||||
impl Preserved {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none()
|
||||
self.package_rels.is_empty()
|
||||
&& self.rels.is_empty()
|
||||
&& self.parts.is_empty()
|
||||
&& self.elements.is_empty()
|
||||
&& self.vml.is_none()
|
||||
&& self.sheets.is_empty()
|
||||
}
|
||||
/// Total bytes held.
|
||||
pub fn byte_len(&self) -> usize {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@ pub struct Sheet {
|
|||
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
|
||||
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
|
||||
|
||||
/// Column width (points) → characters of the default font's digit width (7 px), without the
|
||||
/// cell padding: the value the Column Width dialog shows.
|
||||
pub fn points_to_chars(px: f64) -> f64 {
|
||||
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
|
|
|
|||
|
|
@ -377,6 +377,12 @@ impl Default for StyleTable {
|
|||
}
|
||||
|
||||
impl StyleTable {
|
||||
/// A table whose default (Normal) style is `normal`, such as a file's own Normal font.
|
||||
pub fn with_default(normal: Style) -> Self {
|
||||
let mut index = HashMap::new();
|
||||
index.insert(normal.clone(), StyleId::DEFAULT);
|
||||
StyleTable { styles: vec![normal], index }
|
||||
}
|
||||
pub fn get(&self, id: StyleId) -> &Style {
|
||||
self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -116,6 +116,10 @@ pub struct Workbook {
|
|||
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
/// The folder the workbook was read from, which relative paths to linked workbooks in
|
||||
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub source_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Workbook {
|
||||
|
|
@ -143,6 +147,7 @@ impl Workbook {
|
|||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
preserved: None,
|
||||
source_dir: None,
|
||||
}
|
||||
}
|
||||
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
|
||||
|
|
|
|||
|
|
@ -98,13 +98,38 @@ impl Default for NumberFormat {
|
|||
}
|
||||
}
|
||||
|
||||
/// Longest number format code, in characters (not bytes), that is parsed: Excel's own limit.
|
||||
/// A longer code is invalid: [`NumberFormat::parse`] returns a General format (the cell shows
|
||||
/// its value unformatted; the stored code is kept as is) and `TEXT` returns `#VALUE!`. The cap
|
||||
/// also bounds every allocation that scales with the number of placeholders in a code.
|
||||
pub const MAX_FORMAT_CODE_CHARS: usize = 255;
|
||||
|
||||
/// True when `code` is longer than [`MAX_FORMAT_CODE_CHARS`] characters.
|
||||
fn too_long(code: &str) -> bool {
|
||||
// A code of more than 255 bytes can still be short in characters; count only when needed.
|
||||
code.len() > MAX_FORMAT_CODE_CHARS && code.chars().count() > MAX_FORMAT_CODE_CHARS
|
||||
}
|
||||
|
||||
impl NumberFormat {
|
||||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
|
||||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and
|
||||
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General.
|
||||
pub fn parse(code: &str) -> NumberFormat {
|
||||
Self::parse_inner(code, false)
|
||||
}
|
||||
|
||||
/// `parse` without the length cap, so tests can reach renderer paths a capped code cannot.
|
||||
#[cfg(test)]
|
||||
fn parse_uncapped(code: &str) -> NumberFormat {
|
||||
Self::parse_inner(code, true)
|
||||
}
|
||||
|
||||
fn parse_inner(code: &str, uncapped: bool) -> NumberFormat {
|
||||
#[cfg(test)]
|
||||
parse_cache::count_parse();
|
||||
let trimmed = code.trim();
|
||||
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
|
||||
// value displays as General.
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" {
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) {
|
||||
return NumberFormat {
|
||||
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
|
||||
};
|
||||
|
|
@ -295,6 +320,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
|
|||
}
|
||||
}
|
||||
|
||||
/// A small per-thread cache of parsed format codes for callers that format many values with the
|
||||
/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only
|
||||
/// (the date system and values are applied when formatting), so the code is the whole key.
|
||||
mod parse_cache {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::NumberFormat;
|
||||
|
||||
/// Most codes kept per thread. A full cache is emptied, so memory stays bounded.
|
||||
pub(super) const CAPACITY: usize = 64;
|
||||
/// Longer codes are parsed every time and never stored.
|
||||
pub(super) const MAX_CODE_LEN: usize = 256;
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<String, NumberFormat>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub(super) fn get(code: &str) -> NumberFormat {
|
||||
if code.len() > MAX_CODE_LEN {
|
||||
return NumberFormat::parse(code);
|
||||
}
|
||||
// Not usable (thread shutting down, re-entered): parse without the cache.
|
||||
let cached = CACHE
|
||||
.try_with(|c| {
|
||||
let mut c = c.try_borrow_mut().ok()?;
|
||||
if let Some(f) = c.get(code) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
let f = NumberFormat::parse(code);
|
||||
if c.len() >= CAPACITY {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(code.to_string(), f.clone());
|
||||
Some(f)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
cached.unwrap_or_else(|| NumberFormat::parse(code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static PARSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn count_parse() {
|
||||
let _ = PARSES.try_with(|p| p.set(p.get() + 1));
|
||||
}
|
||||
|
||||
/// Parses done on this thread so far (tests only).
|
||||
#[cfg(test)]
|
||||
pub(super) fn parses() -> usize {
|
||||
PARSES.try_with(std::cell::Cell::get).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Empties this thread's cache (tests only).
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear() {
|
||||
let _ = CACHE.try_with(|c| c.borrow_mut().clear());
|
||||
}
|
||||
}
|
||||
|
||||
impl NumberFormat {
|
||||
/// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so
|
||||
/// formatting many values with one code parses it once. Same result as `parse`.
|
||||
pub fn parse_cached(code: &str) -> NumberFormat {
|
||||
parse_cache::get(code)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
|
||||
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
|
||||
/// section, or are returned as is.
|
||||
|
|
@ -302,7 +399,10 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
|||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let fmt = NumberFormat::parse(code);
|
||||
if too_long(code) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let fmt = NumberFormat::parse_cached(code);
|
||||
let n = match v.scalar() {
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
|
|
|
|||
|
|
@ -125,12 +125,20 @@ pub(crate) struct Section {
|
|||
}
|
||||
|
||||
/// Splits a code into sections at unquoted, unescaped, unbracketed semicolons.
|
||||
///
|
||||
/// A `[` opens a bracket only when a `]` follows it; the bracket then runs to the next `]` (quotes
|
||||
/// inside it are not special), exactly as `tokenize` reads it. An unclosed `[` is a literal in both
|
||||
/// passes, so a later `;` still separates sections. Linear: each `[` either jumps over its bracket
|
||||
/// or, with no `]` left in the code, is a literal at once.
|
||||
fn split_sections(code: &str) -> Vec<String> {
|
||||
let cs: Vec<char> = code.chars().collect();
|
||||
let last_close = cs.iter().rposition(|&c| c == ']');
|
||||
let mut out = vec![String::new()];
|
||||
let mut quoted = false;
|
||||
let mut bracket = false;
|
||||
let mut escape = false;
|
||||
for c in code.chars() {
|
||||
let mut i = 0usize;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
i += 1;
|
||||
let cur = match out.last_mut() {
|
||||
Some(c) => c,
|
||||
None => break,
|
||||
|
|
@ -145,19 +153,22 @@ fn split_sections(code: &str) -> Vec<String> {
|
|||
quoted = !quoted;
|
||||
cur.push(c);
|
||||
}
|
||||
'\\' | '_' | '*' if !quoted && !bracket => {
|
||||
'\\' | '_' | '*' if !quoted => {
|
||||
escape = true;
|
||||
cur.push(c);
|
||||
}
|
||||
'[' if !quoted => {
|
||||
bracket = true;
|
||||
'[' if !quoted && last_close.is_some_and(|l| l >= i) => {
|
||||
// `i` is already past the `[`; the closing bracket is at or after it.
|
||||
cur.push(c);
|
||||
while let Some(&d) = cs.get(i) {
|
||||
i += 1;
|
||||
cur.push(d);
|
||||
if d == ']' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
']' if !quoted => {
|
||||
bracket = false;
|
||||
cur.push(c);
|
||||
}
|
||||
';' if !quoted && !bracket => {
|
||||
';' if !quoted => {
|
||||
if out.len() >= MAX_SECTIONS {
|
||||
break;
|
||||
}
|
||||
|
|
@ -246,6 +257,8 @@ fn tokenize(src: &str) -> Raw {
|
|||
let cs: Vec<char> = src.chars().collect();
|
||||
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false };
|
||||
let toks = &mut raw.toks;
|
||||
// With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear.
|
||||
let last_close = cs.iter().rposition(|&c| c == ']');
|
||||
let mut i = 0usize;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
match c {
|
||||
|
|
@ -290,7 +303,7 @@ fn tokenize(src: &str) -> Raw {
|
|||
continue;
|
||||
}
|
||||
'[' => {
|
||||
let close = cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']'));
|
||||
let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None };
|
||||
if let Some(p) = close {
|
||||
let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect();
|
||||
bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content);
|
||||
|
|
@ -513,6 +526,19 @@ fn analyze(raw: Raw) -> Section {
|
|||
let mut seen_exp = false;
|
||||
let mut seen_digit = false;
|
||||
let mut fraction_slash = false;
|
||||
// Precomputed so each comma and slash is O(1) (a long run of them stays linear): whether the
|
||||
// first non-comma token after a position is a digit, and where the last exponent marker is.
|
||||
let mut digit_after_commas = vec![false; toks.len()];
|
||||
let mut after = false;
|
||||
for (j, t) in toks.iter().enumerate().rev() {
|
||||
if let Some(slot) = digit_after_commas.get_mut(j) {
|
||||
*slot = after;
|
||||
}
|
||||
if !matches!(t, Tok::Comma) {
|
||||
after = matches!(t, Tok::Digit(_));
|
||||
}
|
||||
}
|
||||
let last_exp = toks.iter().rposition(|t| matches!(t, Tok::Exp { .. }));
|
||||
let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. }));
|
||||
for i in 0..toks.len() {
|
||||
let Some(t) = toks.get(i).cloned() else { break };
|
||||
|
|
@ -543,8 +569,7 @@ fn analyze(raw: Raw) -> Section {
|
|||
}
|
||||
}
|
||||
Tok::Comma => {
|
||||
let next = toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Comma)));
|
||||
let next_digit = matches!(next, Some(Tok::Digit(_)));
|
||||
let next_digit = digit_after_commas.get(i).copied().unwrap_or(false);
|
||||
if seen_digit && next_digit && !seen_point && !seen_exp {
|
||||
sec.thousands = true;
|
||||
Some(Tok::Lit(String::new()))
|
||||
|
|
@ -563,7 +588,8 @@ fn analyze(raw: Raw) -> Section {
|
|||
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|
||||
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
|
||||
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
|
||||
// An exponent kept before this bar (`seen_exp`) or any marker still ahead of it.
|
||||
let has_exp = seen_exp || last_exp.is_some_and(|l| l > i);
|
||||
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
|
||||
fraction_slash = true;
|
||||
sec.fraction = true;
|
||||
|
|
|
|||
|
|
@ -509,7 +509,9 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
|||
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
|
||||
// pads with spaces after them.
|
||||
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
|
||||
let padded = format!("{den_s:0>zeros$}");
|
||||
// No `{:0>zeros$}`: a format width above u16::MAX panics.
|
||||
let mut padded = "0".repeat(zeros.saturating_sub(den_s.chars().count()));
|
||||
padded.push_str(&den_s);
|
||||
let dc: Vec<char> = padded.chars().collect();
|
||||
let count = den_idx.len();
|
||||
for (k, &ti) in den_idx.iter().enumerate() {
|
||||
|
|
|
|||
|
|
@ -614,3 +614,253 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() {
|
|||
let (d, e) = decimal_digits(999999999999999.6);
|
||||
assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15));
|
||||
}
|
||||
|
||||
// ---- parse cache ----
|
||||
|
||||
/// `TEXT()` as it was before the cache: parse the code, then format.
|
||||
fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let fmt = NumberFormat::parse(code);
|
||||
let n = match v.scalar() {
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
Value::Number(n) => n,
|
||||
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
|
||||
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
|
||||
Some(n) => n,
|
||||
None => return Ok(fmt.format_text(&t).text),
|
||||
},
|
||||
Value::Array(_) => return Err(CellError::Value),
|
||||
};
|
||||
if !n.is_finite() {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let (f, overflow) = fmt.format_number(n, sys);
|
||||
if overflow { Err(CellError::Value) } else { Ok(f.text) }
|
||||
}
|
||||
|
||||
const CACHE_CODES: &[&str] = &[
|
||||
"0",
|
||||
"0.00",
|
||||
"#,##0.00",
|
||||
"#,##0.00;[Red](#,##0.00)",
|
||||
"$#,##0.00",
|
||||
"0%",
|
||||
"0.0%",
|
||||
"0.00E+00",
|
||||
"##0.0E+0",
|
||||
"General",
|
||||
"general",
|
||||
"checkbox",
|
||||
"@",
|
||||
"0;@",
|
||||
"0;-0;;@",
|
||||
"00000",
|
||||
"# ?/?",
|
||||
"# ??/16",
|
||||
"?/?",
|
||||
"m/d/yyyy",
|
||||
"yyyy-mm-dd hh:mm:ss",
|
||||
"dddd, mmmm d, yyyy",
|
||||
"h:mm AM/PM",
|
||||
"[h]:mm:ss",
|
||||
"mm:ss.000",
|
||||
"[Red]0;[Blue]-0",
|
||||
"[>100]0.0;[<5]0.000;0",
|
||||
"[<=9999999]###-####;(###) ###-####",
|
||||
"[$-409]d mmm yy",
|
||||
"\"abc\"0",
|
||||
"\\x0",
|
||||
"*-0",
|
||||
"_(* #,##0_)",
|
||||
"0.0.0",
|
||||
"[",
|
||||
"\"abc",
|
||||
"0;0;0;0;0",
|
||||
"#,##0.00;[Red",
|
||||
"[$-]0",
|
||||
" 0.00 ",
|
||||
"[Color99]0",
|
||||
"0.00_);(0.00)",
|
||||
"#,,\"M\"",
|
||||
"yyyy",
|
||||
"",
|
||||
];
|
||||
|
||||
fn cache_values() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Number(0.0),
|
||||
Value::Number(1234.5678),
|
||||
Value::Number(-42.5),
|
||||
Value::Number(0.5),
|
||||
Value::Number(45000.75),
|
||||
Value::Number(1e15),
|
||||
Value::Number(1e-7),
|
||||
Value::Number(-0.0001),
|
||||
Value::Number(f64::INFINITY),
|
||||
Value::from("abc"),
|
||||
Value::from("12.5"),
|
||||
Value::Bool(true),
|
||||
Value::Empty,
|
||||
Value::Error(CellError::NA),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cached_gives_the_same_results_cold_and_warm() {
|
||||
assert!(CACHE_CODES.len() >= 40);
|
||||
parse_cache::clear();
|
||||
let values = cache_values();
|
||||
for sys in [DateSystem::D1900, DateSystem::D1904] {
|
||||
// Cold on the first system, warm on the second.
|
||||
for code in CACHE_CODES {
|
||||
let direct = NumberFormat::parse(code);
|
||||
let cached = NumberFormat::parse_cached(code);
|
||||
assert_eq!(cached.code(), direct.code(), "{code:?}");
|
||||
assert_eq!(
|
||||
(cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()),
|
||||
(direct.kind(), direct.is_date(), direct.is_text(), direct.is_general())
|
||||
);
|
||||
for v in &values {
|
||||
let want = text_uncached(v, code, sys);
|
||||
assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})");
|
||||
// Again, now certainly warm.
|
||||
assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})");
|
||||
assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cached_parses_each_code_once() {
|
||||
parse_cache::clear();
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
|
||||
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
|
||||
}
|
||||
// "0.00" is still cached from above: only the second code is new.
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
parse_cache::clear();
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
|
||||
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cache_is_bounded() {
|
||||
parse_cache::clear();
|
||||
// More distinct codes than the capacity: all still format correctly...
|
||||
let n = parse_cache::CAPACITY + 10;
|
||||
for i in 0..n {
|
||||
let c = format!("0.00\"{i}\"");
|
||||
assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}")));
|
||||
}
|
||||
// ...the first ones were evicted (parsed again), the newest is still cached.
|
||||
let before = parse_cache::parses();
|
||||
let newest = format!("0.00\"{}\"", n - 1);
|
||||
assert!(text_function(&Value::Number(1.0), &newest, S).is_ok());
|
||||
assert_eq!(parse_cache::parses() - before, 0);
|
||||
assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok());
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
// A code too long (in bytes) for the cache is parsed on every call and not stored. Two-byte
|
||||
// characters keep it under the 255-character cap that TEXT() refuses past.
|
||||
let long = format!("0\"{}\"", "é".repeat(parse_cache::MAX_CODE_LEN / 2 + 8));
|
||||
let before = parse_cache::parses();
|
||||
for _ in 0..3 {
|
||||
assert!(text_function(&Value::Number(1.0), &long, S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fraction_denominator_with_65536_zeros_does_not_panic() {
|
||||
// `{:0>n$}` panics for a width above u16::MAX; the cap keeps this out of reach of `parse`,
|
||||
// so the renderer is driven with the uncapped parser.
|
||||
let code = format!("# ?/{}", "0".repeat(65_536));
|
||||
let fmt = NumberFormat::parse_uncapped(&code);
|
||||
let text = format_value(&Value::Number(0.5), &fmt, S).text;
|
||||
assert!(text.starts_with(" 1/"), "{text}");
|
||||
// And a denominator with fewer digits than zeros is still zero padded.
|
||||
assert_eq!(f("# ?/000", 0.5), " 1/002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codes_are_capped_at_255_characters() {
|
||||
let ok = format!("0{}", "0".repeat(254));
|
||||
let long = format!("0{}", "0".repeat(255));
|
||||
assert_eq!(ok.chars().count(), MAX_FORMAT_CODE_CHARS);
|
||||
assert!(text_function(&Value::Number(1.0), &ok, S).is_ok());
|
||||
assert_eq!(text_function(&Value::Number(1.0), &long, S), Err(CellError::Value));
|
||||
// Capped codes display as General.
|
||||
assert_eq!(f(&long, 0.5), "0.5");
|
||||
// Characters are counted, not bytes: 255 two-byte characters are fine, 256 are not.
|
||||
let multi_ok = "é".repeat(255);
|
||||
assert!(multi_ok.len() > 255);
|
||||
assert!(text_function(&Value::Number(1.0), &multi_ok, S).is_ok());
|
||||
assert_eq!(text_function(&Value::Number(1.0), &"é".repeat(256), S), Err(CellError::Value));
|
||||
// 65536 zeros through the public API: no panic, TEXT is #VALUE!.
|
||||
let huge = format!("# ?/{}", "0".repeat(65_536));
|
||||
assert_eq!(text_function(&Value::Number(0.5), &huge, S), Err(CellError::Value));
|
||||
assert_eq!(f(&huge, 0.5), "0.5");
|
||||
}
|
||||
|
||||
/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic
|
||||
/// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes
|
||||
/// minutes and an O(n) pass milliseconds, so the margin is decades wide.
|
||||
fn finishes_in_time(code: String) -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
// Uncapped: `parse` turns a code past 255 characters into General before the parser sees it.
|
||||
let _ = tx.send(format_value(&Value::Number(1234.5), &NumberFormat::parse_uncapped(&code), S).text);
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_codes_parse_in_linear_time() {
|
||||
let n = 1_000_000;
|
||||
// Unclosed brackets, a run of commas after a digit, a run of fraction bars.
|
||||
assert!(finishes_in_time("[".repeat(n)).starts_with('['));
|
||||
finishes_in_time(format!("0{}", ",".repeat(n)));
|
||||
finishes_in_time(format!("0{}", "/".repeat(n)));
|
||||
finishes_in_time(format!("0{}0", ",".repeat(n)));
|
||||
// Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one.
|
||||
finishes_in_time("[;".repeat(n / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_bracket_is_a_literal_in_both_passes() {
|
||||
// Sections still split after an unclosed `[`.
|
||||
assert_eq!(f("[0;0", 5.0), "[5");
|
||||
assert_eq!(f("0;[0", -5.0), "[5");
|
||||
assert_eq!(f("0;-0;[", 0.0), "[");
|
||||
assert_eq!(parse::parse_sections("0;[Red-0").len(), 2);
|
||||
assert_eq!(parse::parse_sections("[0;0").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_brackets_still_hold_their_semicolons() {
|
||||
assert_eq!(f("[Red]0;[Blue]-0", -5.0), "-5");
|
||||
assert_eq!(f("[>=100]0.0;0", 150.0), "150.0");
|
||||
assert_eq!(f("[$-409]0.00", 2.5), "2.50");
|
||||
assert_eq!(f("[$€-2] #,##0", 1234.0), "€ 1,234");
|
||||
assert_eq!(parse::parse_sections("[Red]0;[Blue]0").len(), 2);
|
||||
assert_eq!(parse::parse_sections("[unknown;x]0;0").len(), 2);
|
||||
assert_eq!(f("0\\[0", 5.0), "0[5");
|
||||
assert_eq!(f("\"[\"0;0", 5.0), "[5");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ impl Font {
|
|||
|
||||
/// Width of `text` in points when set in `font` at `size` points.
|
||||
pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
|
||||
let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum();
|
||||
// u64: a u32 sum overflows for ~8 million characters.
|
||||
let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum();
|
||||
units as f32 * size / 1000.0
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +222,32 @@ fn n(v: f32) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when every value is finite (NaN and infinities are rejected).
|
||||
fn finite(v: &[f32]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// A positive, finite extent (a width, height or radius).
|
||||
fn extent(v: f32) -> bool {
|
||||
v > 0.0 && v.is_finite()
|
||||
}
|
||||
|
||||
/// A stroke width: negative, NaN and infinite become 0 (the thinnest line).
|
||||
fn stroke_width(w: f32) -> f32 {
|
||||
if w.is_finite() { w.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification
|
||||
/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is
|
||||
/// replaced by an empty array, i.e. a solid line.
|
||||
fn dash_values(dash: &[f32]) -> String {
|
||||
let d: Vec<f32> = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect();
|
||||
if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) {
|
||||
return String::new();
|
||||
}
|
||||
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
|
|
@ -288,7 +315,7 @@ impl Page {
|
|||
}
|
||||
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
if !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
|
|
@ -296,7 +323,10 @@ impl Page {
|
|||
}
|
||||
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
if !finite(&[x, y, w, h]) {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
|
|
@ -306,19 +336,28 @@ impl Page {
|
|||
|
||||
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
|
||||
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let s =
|
||||
format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1)));
|
||||
if !finite(&[x0, y0, x1, y1]) {
|
||||
return;
|
||||
}
|
||||
let s = format!(
|
||||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||||
rgb_op(color, true),
|
||||
n(stroke_width(width)),
|
||||
dash_values(dash),
|
||||
n(x0),
|
||||
n(self.y(y0)),
|
||||
n(x1),
|
||||
n(self.y(y1))
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
/// An open polyline.
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 {
|
||||
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" "));
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
|
|
@ -335,7 +374,7 @@ impl Page {
|
|||
|
||||
/// A closed polygon, filled and/or stroked.
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
|
|
@ -344,7 +383,7 @@ impl Page {
|
|||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w));
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
|
|
@ -357,7 +396,7 @@ impl Page {
|
|||
|
||||
/// A circle approximated by four cubic Béziers.
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
|
||||
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
|
|
@ -369,7 +408,7 @@ impl Page {
|
|||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w));
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
|
|
@ -417,7 +456,7 @@ impl Page {
|
|||
/// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate
|
||||
/// system) about its baseline origin.
|
||||
pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
|
||||
if text.is_empty() || !(size.is_finite() && size > 0.0) {
|
||||
if text.is_empty() || !extent(size) || !finite(&[x, y]) {
|
||||
return;
|
||||
}
|
||||
let fi = font.index();
|
||||
|
|
@ -446,7 +485,7 @@ impl Page {
|
|||
/// `(x, y, w, h)`. Malformed or oversized buffers are ignored.
|
||||
pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) {
|
||||
let px = pw as u64 * ph as u64;
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 {
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
return;
|
||||
}
|
||||
let px = px as usize;
|
||||
|
|
@ -516,8 +555,23 @@ fn run_length(data: &[u8]) -> Vec<u8> {
|
|||
out
|
||||
}
|
||||
|
||||
fn pdf_text_string(s: &str) -> String {
|
||||
pdf_string(s)
|
||||
/// Longest Info string written, in characters; longer values are cut on a character boundary.
|
||||
const MAX_INFO_CHARS: usize = 1024;
|
||||
|
||||
/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII,
|
||||
/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2).
|
||||
fn pdf_text_string(text: &str) -> String {
|
||||
let text: String = text.chars().take(MAX_INFO_CHARS).collect();
|
||||
if text.chars().all(|c| matches!(c, ' '..='~')) {
|
||||
return pdf_string(&text);
|
||||
}
|
||||
let mut s = String::with_capacity(text.len() * 4 + 6);
|
||||
s.push_str("<FEFF");
|
||||
for unit in text.encode_utf16() {
|
||||
let _ = write!(s, "{unit:04X}");
|
||||
}
|
||||
s.push('>');
|
||||
s
|
||||
}
|
||||
|
||||
impl PdfDoc {
|
||||
|
|
|
|||
|
|
@ -157,3 +157,123 @@ fn hostile_inputs_never_panic() {
|
|||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
fn page_content(d: &PdfDoc) -> &str {
|
||||
&d.pages[0].content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_width_does_not_overflow() {
|
||||
// 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds.
|
||||
let long = "a".repeat(8_000_000);
|
||||
let w = text_width(Font::Helvetica, 10.0, &long);
|
||||
assert!(w.is_finite() && w > 0.0);
|
||||
assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_zero_dash_is_drawn_solid() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]);
|
||||
p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]);
|
||||
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]);
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("[] 0 d").count(), 2);
|
||||
assert!(!c.contains("[0 0]"));
|
||||
assert!(c.contains("[3 2] 0 d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_nan_stroke_widths_become_zero() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]);
|
||||
p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]);
|
||||
p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0)));
|
||||
p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY)));
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("\n0 w ").count(), 4);
|
||||
assert!(!c.contains("-1 w") && !c.contains("-3 w"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_image_size_stores_nothing() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
let px = [255u8; 16];
|
||||
for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] {
|
||||
p.image_rgba(0.0, 0.0, w, h, &px, 2, 2);
|
||||
}
|
||||
p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2);
|
||||
assert!(p.images.is_empty());
|
||||
assert!(p.content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_draw_method_survives_hostile_numbers() {
|
||||
let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30];
|
||||
let px = [200u8; 16];
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(300.0, 300.0);
|
||||
for &a in &bad {
|
||||
for &b in &bad {
|
||||
p.clip_rect(a, b, a, b);
|
||||
p.fill_rect(a, b, b, a, [1, 2, 3]);
|
||||
p.rect_stroke(a, b, a, b, a, [1, 2, 3]);
|
||||
p.stroke_line(a, b, b, a, a, [1, 2, 3]);
|
||||
p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]);
|
||||
p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]);
|
||||
p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a)));
|
||||
p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a)));
|
||||
p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x");
|
||||
p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b);
|
||||
p.image_rgba(a, b, b, a, &px, 2, 2);
|
||||
}
|
||||
}
|
||||
p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]);
|
||||
let c = page_content(&d);
|
||||
assert!(!c.contains("NaN") && !c.contains("inf"));
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
/// Decodes the `<FEFF…>` hex string following `key` in the file bytes.
|
||||
fn utf16_entry(pdf: &[u8], key: &str) -> String {
|
||||
let at = find(pdf, key.as_bytes()).unwrap() + key.len();
|
||||
let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or("");
|
||||
let hex = rest.trim_start().strip_prefix("<FEFF").unwrap();
|
||||
let hex = &hex[..hex.find('>').unwrap()];
|
||||
let units: Vec<u16> = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect();
|
||||
String::from_utf16(&units).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_strings_with_non_ascii_are_utf16be() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into();
|
||||
doc.info.author = "Müller & Søn".into();
|
||||
let pdf = doc.finish();
|
||||
assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀");
|
||||
assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_info_strings_stay_literal_and_escaped() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = r"Q1 (draft) a\b".into();
|
||||
let pdf = doc.finish();
|
||||
assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some());
|
||||
assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_strings_are_capped() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = "日".repeat(100_000);
|
||||
let pdf = doc.finish();
|
||||
assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
|
||||
//! Options); Save and Close act at once. Every action runs an existing command through
|
||||
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
|
||||
//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched
|
||||
//! until the user picks one.
|
||||
//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed:
|
||||
//! that is asked of the file system on a thread of its own, never while drawing, and only the
|
||||
//! files' metadata is read. No workbook is opened until the user picks one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
|
@ -109,6 +114,8 @@ pub struct Backstage {
|
|||
preview: Option<((u64, u64, bool, bool), Json)>,
|
||||
/// Save As: the chosen file type (an extension from [`SAVE_TYPES`]).
|
||||
pub save_format: &'static str,
|
||||
/// Home and Open: when the recent workbooks were last changed.
|
||||
recent: RecentFiles,
|
||||
}
|
||||
|
||||
/// Save As file types: (extension, label). The first is the workbook itself; the others are
|
||||
|
|
@ -118,7 +125,16 @@ pub const SAVE_TYPES: [(&str, &str); 4] =
|
|||
|
||||
impl Backstage {
|
||||
pub fn new(page: Page) -> Backstage {
|
||||
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" }
|
||||
Backstage {
|
||||
page,
|
||||
props: Default::default(),
|
||||
print_all: false,
|
||||
fit_to_page: false,
|
||||
preview_page: 0,
|
||||
preview: None,
|
||||
save_format: "xlsx",
|
||||
recent: RecentFiles::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +203,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text));
|
||||
ui.add_space(18.0);
|
||||
match bs.page {
|
||||
Page::Home => home(app, ui, &mut out),
|
||||
Page::Home => home(app, ui, &mut bs, &mut out),
|
||||
Page::New => new_page(app, ui, &mut out),
|
||||
Page::Open => open_page(app, ui, &mut out),
|
||||
Page::Open => open_page(app, ui, &mut bs, &mut out),
|
||||
Page::Info => info(app, ui, &mut bs),
|
||||
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
|
||||
Page::Print => print(app, ui, &mut bs, &mut out),
|
||||
|
|
@ -325,7 +341,7 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
|||
});
|
||||
}
|
||||
|
||||
/// File name and folder of a recent path, split as text (nothing on disk is read).
|
||||
/// 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(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
|
|
@ -333,8 +349,12 @@ fn split_path(path: &str) -> (String, String) {
|
|||
(name, folder)
|
||||
}
|
||||
|
||||
/// Recent workbooks, newest first. Opening one is the user's explicit choice.
|
||||
fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) {
|
||||
/// Width of the column that tells when a recent workbook was last changed.
|
||||
const MODIFIED_W: f32 = 150.0;
|
||||
|
||||
/// Recent workbooks, newest first, each with when it was last changed. Opening one is the user's
|
||||
/// explicit choice.
|
||||
fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome, limit: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
if app.ui.recent.is_empty() {
|
||||
|
|
@ -342,6 +362,21 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
|||
return;
|
||||
}
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
// What the file system has said so far; the rest arrives in later frames (see `RecentFiles`).
|
||||
let known = bs.recent.known(ui.ctx(), &app.ui.recent);
|
||||
// The web build has no files to ask about, and a narrow window leaves the names their room.
|
||||
let modified_w = if cfg!(target_arch = "wasm32") || width < 420.0 { 0.0 } else { MODIFIED_W };
|
||||
if modified_w > 0.0 {
|
||||
let (head, _) = ui.allocate_exact_size(vec2(width, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
pos2(head.right() - modified_w, head.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
lang.tr("Modified"),
|
||||
theme::ui_font(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
let now = crate::now_ms() / 1000.0;
|
||||
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
|
|
@ -351,8 +386,19 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
|||
p.rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
|
||||
// A long name or folder stops short of the column instead of running under it.
|
||||
let room = if modified_w > 0.0 { rect.right() - modified_w - 14.0 } else { rect.right() };
|
||||
let text = p.with_clip_rect(Rect::from_min_max(rect.min, pos2(room, rect.bottom())).intersect(p.clip_rect()));
|
||||
text.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
|
||||
text.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
|
||||
if modified_w > 0.0 {
|
||||
let (words, color) = match known.get(&path) {
|
||||
Some(Modified::At(seconds)) => (age(lang, now - seconds), t.text_dim),
|
||||
Some(Modified::Unknown) => (lang.tr("Not available").to_string(), t.text_disabled),
|
||||
None => (String::new(), t.text_dim),
|
||||
};
|
||||
p.text(pos2(rect.right() - modified_w, rect.center().y), Align2::LEFT_CENTER, words, theme::ui_font(12.0), color);
|
||||
}
|
||||
let resp = resp.on_hover_text(&path);
|
||||
if resp.clicked() {
|
||||
app.open_path(&path);
|
||||
|
|
@ -363,6 +409,98 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
|||
}
|
||||
}
|
||||
|
||||
/// What the file system said about a recent workbook.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Modified {
|
||||
/// When it was last changed, in seconds since 1970.
|
||||
At(f64),
|
||||
/// It can't be reached: moved, deleted, or on a drive that is away.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One thread at a time asks about recent workbooks, so a drive that never answers holds up one
|
||||
/// thread, not one for every time the view is opened.
|
||||
static ASKING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// When the listed workbooks were last changed, as far as the file system has answered.
|
||||
///
|
||||
/// Asking can take long (a network drive that is asleep, a share that is away), so it is never
|
||||
/// done while drawing: a thread asks, and the list shows each answer as it comes back. Only the
|
||||
/// files' metadata is read. A view opened anew asks anew.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecentFiles {
|
||||
/// The list the answers are for.
|
||||
asked: Vec<String>,
|
||||
found: Arc<Mutex<HashMap<String, Modified>>>,
|
||||
}
|
||||
|
||||
impl RecentFiles {
|
||||
/// What is known this frame about the files in `recent`; starts asking when the list is new.
|
||||
fn known(&mut self, ctx: &egui::Context, recent: &[String]) -> HashMap<String, Modified> {
|
||||
if self.asked != recent && !ASKING.swap(true, Ordering::SeqCst) {
|
||||
self.asked = recent.to_vec();
|
||||
if !ask(ctx.clone(), recent.to_vec(), self.found.clone()) {
|
||||
ASKING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
self.found.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the thread that asks. Returns whether it started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ask(ctx: egui::Context, paths: Vec<String>, found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
|
||||
/// Lets the next list ask, however the thread ends.
|
||||
struct Done;
|
||||
impl Drop for Done {
|
||||
fn drop(&mut self) {
|
||||
ASKING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let asking = std::thread::Builder::new().name("gridcraft-recent-files".into()).spawn(move || {
|
||||
let _done = Done;
|
||||
for path in paths {
|
||||
let changed = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok());
|
||||
let answer = changed.map_or(Modified::Unknown, |since| Modified::At(since.as_secs_f64()));
|
||||
found.lock().unwrap_or_else(PoisonError::into_inner).insert(path, answer);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
asking.is_ok()
|
||||
}
|
||||
|
||||
/// The web build has no file system to ask.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn ask(_ctx: egui::Context, _paths: Vec<String>, _found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// How long ago, in words. The clock's time zone isn't known here, so the list tells ages rather
|
||||
/// than dates, which could be a day out. From two days on the counts are never one, so each
|
||||
/// language needs one form of "days", "weeks", "months" and "years".
|
||||
fn age(lang: Language, seconds: f64) -> String {
|
||||
const DAY: f64 = 86_400.0;
|
||||
let s = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 };
|
||||
let counted = |pattern: &str, n: f64| lang.tr(pattern).replace("{n}", &(n.floor() as u64).to_string());
|
||||
if s < 60.0 {
|
||||
lang.tr("Just now").to_string()
|
||||
} else if s < 3600.0 {
|
||||
counted("{n} min ago", s / 60.0)
|
||||
} else if s < DAY {
|
||||
counted("{n} h ago", s / 3600.0)
|
||||
} else if s < 2.0 * DAY {
|
||||
lang.tr("Yesterday").to_string()
|
||||
} else if s < 14.0 * DAY {
|
||||
counted("{n} days ago", s / DAY)
|
||||
} else if s < 61.0 * DAY {
|
||||
counted("{n} weeks ago", s / (7.0 * DAY))
|
||||
} else if s < 731.0 * DAY {
|
||||
counted("{n} months ago", s / (30.44 * DAY))
|
||||
} else {
|
||||
counted("{n} years ago", s / (365.25 * DAY))
|
||||
}
|
||||
}
|
||||
|
||||
/// A large clickable row (icon, title, one-line description).
|
||||
fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
|
@ -395,13 +533,13 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
|
|||
|
||||
// ---------------------------------------------------------------- pages
|
||||
|
||||
fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
fn home(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "New");
|
||||
new_tiles(app, ui, out);
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, 8);
|
||||
recent_list(app, ui, bs, out, 8);
|
||||
if app.ui.recent.len() > 8 {
|
||||
ui.add_space(6.0);
|
||||
if ui.link(lang.tr("More workbooks")).clicked() {
|
||||
|
|
@ -414,7 +552,7 @@ fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
|||
new_tiles(app, ui, out);
|
||||
}
|
||||
|
||||
fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
fn open_page(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() {
|
||||
let before = app.session.active().map(|d| d.uid);
|
||||
|
|
@ -426,7 +564,7 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
|||
}
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, usize::MAX);
|
||||
recent_list(app, ui, bs, out, usize::MAX);
|
||||
}
|
||||
|
||||
/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an
|
||||
|
|
@ -856,6 +994,76 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ages_are_told_in_words() {
|
||||
const DAY: f64 = 86_400.0;
|
||||
for (seconds, words) in [
|
||||
(0.0, "Just now"),
|
||||
(59.9, "Just now"),
|
||||
(60.0, "1 min ago"),
|
||||
(150.0, "2 min ago"),
|
||||
(3600.0, "1 h ago"),
|
||||
(23.9 * 3600.0, "23 h ago"),
|
||||
(DAY, "Yesterday"),
|
||||
(1.99 * DAY, "Yesterday"),
|
||||
// From here on a count is never one.
|
||||
(2.0 * DAY, "2 days ago"),
|
||||
(13.9 * DAY, "13 days ago"),
|
||||
(14.0 * DAY, "2 weeks ago"),
|
||||
(60.9 * DAY, "8 weeks ago"),
|
||||
(61.0 * DAY, "2 months ago"),
|
||||
(730.9 * DAY, "24 months ago"),
|
||||
(731.0 * DAY, "2 years ago"),
|
||||
(4000.0 * DAY, "10 years ago"),
|
||||
// A clock that is behind the file's, or a number that isn't one.
|
||||
(-5.0, "Just now"),
|
||||
(f64::NAN, "Just now"),
|
||||
(f64::NEG_INFINITY, "Just now"),
|
||||
(f64::INFINITY, "Just now"),
|
||||
] {
|
||||
assert_eq!(age(Language::En, seconds), words, "{seconds}");
|
||||
}
|
||||
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
|
||||
// Other languages put the count where their own words want it.
|
||||
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
|
||||
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
|
||||
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
|
||||
for lang in Language::ALL {
|
||||
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
|
||||
let words = age(lang, seconds);
|
||||
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recent_list_learns_when_its_files_were_last_changed() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-recent-files-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let there = dir.join("There.xlsx").to_string_lossy().into_owned();
|
||||
let gone = dir.join("Gone.xlsx").to_string_lossy().into_owned();
|
||||
std::fs::write(&there, b"a file, if not a workbook").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let recent = vec![there.clone(), gone.clone()];
|
||||
let mut files = RecentFiles::default();
|
||||
// Nothing is read while drawing: the answers arrive from a thread, in later frames.
|
||||
let mut known = HashMap::new();
|
||||
for _ in 0..2_000 {
|
||||
known = files.known(&ctx, &recent);
|
||||
if known.len() == recent.len() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(matches!(known.get(&there), Some(Modified::At(seconds)) if *seconds > 1.0e9), "{known:?}");
|
||||
assert_eq!(known.get(&gone), Some(&Modified::Unknown));
|
||||
// The same list is not asked about twice; an empty one is never asked about.
|
||||
assert_eq!(files.known(&ctx, &recent), known);
|
||||
assert!(RecentFiles::default().known(&ctx, &[]).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
for page in Page::ALL {
|
||||
|
|
|
|||
|
|
@ -349,6 +349,20 @@ impl Dialog {
|
|||
json!({"text": existing}),
|
||||
)
|
||||
}
|
||||
// Asks before a web or e-mail link leaves the workbook; OK runs `link.open`, which
|
||||
// refuses anything but http, https and mailto.
|
||||
"openLink" => {
|
||||
let url = params.get("url").and_then(Json::as_str).unwrap_or_default().to_string();
|
||||
Dialog::form(
|
||||
"openLink",
|
||||
"Open Hyperlink",
|
||||
"link.open",
|
||||
vec![Note(format!(
|
||||
"This workbook asks to open the address below. Links can lead to harmful sites or start other programs: open it only if you trust the workbook.\n\n{url}"
|
||||
))],
|
||||
json!({"url": url}),
|
||||
)
|
||||
}
|
||||
"insertLink" => Dialog::form(
|
||||
"insertLink",
|
||||
"Insert Hyperlink",
|
||||
|
|
@ -432,6 +446,7 @@ impl Dialog {
|
|||
Text { key: "timeline", label: "Timeline range (blank: first column of the selection):" },
|
||||
Text { key: "values", label: "Values range (blank: second column of the selection):" },
|
||||
Text { key: "end", label: "Forecast end (date or number; blank: a third of the history):" },
|
||||
Text { key: "forecastStart", label: "Forecast start (a date or number in the history; blank: after the last point):" },
|
||||
Check { key: "confidenceInterval", label: "Confidence interval" },
|
||||
Text { key: "confidence", label: "Confidence level (%):" },
|
||||
Text { key: "seasonality", label: "Seasonality (auto, 0 for none, or a season length):" },
|
||||
|
|
@ -551,47 +566,7 @@ impl Dialog {
|
|||
"dataValidation",
|
||||
"Data Validation",
|
||||
"data.validation",
|
||||
vec![
|
||||
Choice {
|
||||
key: "type",
|
||||
label: "Allow",
|
||||
options: vec![
|
||||
("any", "Any value"),
|
||||
("whole", "Whole number"),
|
||||
("decimal", "Decimal"),
|
||||
("list", "List"),
|
||||
("date", "Date"),
|
||||
("time", "Time"),
|
||||
("textLength", "Text length"),
|
||||
("custom", "Custom"),
|
||||
],
|
||||
},
|
||||
Choice {
|
||||
key: "operator",
|
||||
label: "Data",
|
||||
options: vec![
|
||||
("between", "between"),
|
||||
("notBetween", "not between"),
|
||||
("equal", "equal to"),
|
||||
("notEqual", "not equal to"),
|
||||
("greater", "greater than"),
|
||||
("less", "less than"),
|
||||
("greaterOrEqual", "greater than or equal to"),
|
||||
("lessOrEqual", "less than or equal to"),
|
||||
],
|
||||
},
|
||||
Text { key: "formula1", label: "Minimum / Source / Formula:" },
|
||||
Text { key: "formula2", label: "Maximum:" },
|
||||
Check { key: "ignoreBlank", label: "Ignore blank" },
|
||||
Check { key: "dropdown", label: "In-cell dropdown" },
|
||||
Text { key: "inputMessage", label: "Input message:" },
|
||||
Choice {
|
||||
key: "errorStyle",
|
||||
label: "Error style",
|
||||
options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")],
|
||||
},
|
||||
Text { key: "errorMessage", label: "Error message:" },
|
||||
],
|
||||
data_validation_fields("any", "between"),
|
||||
validation_defaults(app),
|
||||
),
|
||||
"cfQuick" => {
|
||||
|
|
@ -859,10 +834,132 @@ fn abs(r: &gridcraft_engine::core::RangeRef) -> String {
|
|||
if r.is_single() { a(r.start) } else { format!("{}:{}", a(r.start), a(r.end)) }
|
||||
}
|
||||
|
||||
/// The criterion controls in Excel's Settings tab depend on both Allow and Data.
|
||||
/// `formula1` is the sole source for a list or custom rule; `formula2` is a
|
||||
/// second bound only for Between and Not between.
|
||||
fn data_validation_fields(kind: &str, operator: &str) -> Vec<Field> {
|
||||
use Field::*;
|
||||
let mut fields = vec![Choice {
|
||||
key: "type",
|
||||
label: "Allow",
|
||||
options: vec![
|
||||
("any", "Any value"),
|
||||
("whole", "Whole number"),
|
||||
("decimal", "Decimal"),
|
||||
("list", "List"),
|
||||
("date", "Date"),
|
||||
("time", "Time"),
|
||||
("textLength", "Text length"),
|
||||
("custom", "Custom"),
|
||||
],
|
||||
}];
|
||||
let two_bounds = matches!(operator, "between" | "notBetween");
|
||||
match kind {
|
||||
"list" => {
|
||||
fields.push(Text { key: "formula1", label: "Source:" });
|
||||
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
|
||||
fields.push(Check { key: "dropdown", label: "In-cell dropdown" });
|
||||
}
|
||||
"custom" => {
|
||||
fields.push(Text { key: "formula1", label: "Formula:" });
|
||||
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
|
||||
}
|
||||
"whole" | "decimal" | "date" | "time" | "textLength" => {
|
||||
fields.push(Choice {
|
||||
key: "operator",
|
||||
label: "Data",
|
||||
options: vec![
|
||||
("between", "between"),
|
||||
("notBetween", "not between"),
|
||||
("equal", "equal to"),
|
||||
("notEqual", "not equal to"),
|
||||
("greater", "greater than"),
|
||||
("less", "less than"),
|
||||
("greaterOrEqual", "greater than or equal to"),
|
||||
("lessOrEqual", "less than or equal to"),
|
||||
],
|
||||
});
|
||||
let lower = matches!(operator, "greater" | "greaterOrEqual");
|
||||
let (first, second) = match kind {
|
||||
"date" => (
|
||||
if two_bounds || lower {
|
||||
"Start date:"
|
||||
} else if matches!(operator, "less" | "lessOrEqual") {
|
||||
"End date:"
|
||||
} else {
|
||||
"Date:"
|
||||
},
|
||||
"End date:",
|
||||
),
|
||||
"time" => (
|
||||
if two_bounds || lower {
|
||||
"Start time:"
|
||||
} else if matches!(operator, "less" | "lessOrEqual") {
|
||||
"End time:"
|
||||
} else {
|
||||
"Time:"
|
||||
},
|
||||
"End time:",
|
||||
),
|
||||
_ => (
|
||||
if two_bounds || lower {
|
||||
"Minimum:"
|
||||
} else if matches!(operator, "less" | "lessOrEqual") {
|
||||
"Maximum:"
|
||||
} else {
|
||||
"Value:"
|
||||
},
|
||||
"Maximum:",
|
||||
),
|
||||
};
|
||||
fields.push(Text { key: "formula1", label: first });
|
||||
if two_bounds {
|
||||
fields.push(Text { key: "formula2", label: second });
|
||||
}
|
||||
fields.push(Check { key: "ignoreBlank", label: "Ignore blank" });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
fields.push(Text { key: "inputMessage", label: "Input message:" });
|
||||
fields.push(Choice {
|
||||
key: "errorStyle",
|
||||
label: "Error style",
|
||||
options: vec![("stop", "Stop"), ("warning", "Warning"), ("information", "Information")],
|
||||
});
|
||||
fields.push(Text { key: "errorMessage", label: "Error message:" });
|
||||
fields
|
||||
}
|
||||
|
||||
fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
|
||||
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
|
||||
d.fields = data_validation_fields(kind, operator);
|
||||
form_fields(ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
/// Builds the command params from form values for dialogs that need translation.
|
||||
fn build_params(d: &Dialog) -> Json {
|
||||
let mut v = Json::Object(d.values.clone());
|
||||
match d.name.as_str() {
|
||||
"dataValidation" => {
|
||||
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
|
||||
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
if !matches!(operator, "between" | "notBetween") || matches!(kind, "any" | "list" | "custom") {
|
||||
m.remove("formula2");
|
||||
}
|
||||
if matches!(kind, "any" | "list" | "custom") {
|
||||
m.remove("operator");
|
||||
}
|
||||
if kind == "any" {
|
||||
m.remove("formula1");
|
||||
}
|
||||
if kind != "list" {
|
||||
m.remove("dropdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
"textToColumns" => {
|
||||
let mut delims = Vec::new();
|
||||
for (k, c) in [("tab", "\t"), ("semicolon", ";"), ("comma", ","), ("space", " ")] {
|
||||
|
|
@ -938,7 +1035,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
"solver" => 460.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(d.title.clone())
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
.id(egui::Id::new("dialog").with(&d.name))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
|
|
@ -950,6 +1047,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
|||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
|
||||
"find" => find(app, ui, &mut d),
|
||||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||||
|
|
@ -1270,11 +1368,15 @@ fn paste_special_shortcuts(d: &mut Dialog, ctx: &egui::Context) {
|
|||
fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool) {
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let ok = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(crate::theme::Tokens::get(ui.ctx()).accent));
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let ok = ui.add(
|
||||
egui::Button::new(egui::RichText::new(format!(" {} ", lang.tr("OK"))).color(Color32::WHITE))
|
||||
.fill(crate::theme::Tokens::get(ui.ctx()).accent),
|
||||
);
|
||||
if ok.clicked() || ui.input(|i| i.key_pressed(Key::Enter)) {
|
||||
*confirm = true;
|
||||
}
|
||||
if ui.button(" Cancel ").clicked() {
|
||||
if ui.button(format!(" {} ", lang.tr("Cancel"))).clicked() {
|
||||
*open = false;
|
||||
}
|
||||
});
|
||||
|
|
@ -1333,9 +1435,20 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(label).strong());
|
||||
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
for (val, text) in options {
|
||||
if ui.radio(cur == val, text).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
if d.name == "dataValidation" {
|
||||
let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| *text).unwrap_or("");
|
||||
egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| {
|
||||
for (val, text) in options {
|
||||
if ui.selectable_label(cur == val, text).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for (val, text) in options {
|
||||
if ui.radio(cur == val, text).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1409,9 +1522,20 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
}
|
||||
|
||||
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
// `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn
|
||||
// labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a
|
||||
// different word in some languages.
|
||||
let lang = app.ui.language;
|
||||
ui.horizontal(|ui| {
|
||||
for tab in ["Number", "Alignment", "Font", "Border", "Fill", "Protection"] {
|
||||
if ui.selectable_label(d.tab == tab, tab).clicked() {
|
||||
for (tab, key) in [
|
||||
("Number", "Number"),
|
||||
("Alignment", "Alignment"),
|
||||
("Font", "Font"),
|
||||
("Border", "Border"),
|
||||
("Fill", "Format Cells|Fill"),
|
||||
("Protection", "Protection"),
|
||||
] {
|
||||
if ui.selectable_label(d.tab == tab, lang.tr(key)).clicked() {
|
||||
d.tab = tab.to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -1423,20 +1547,20 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
"Number" => {
|
||||
let mut code = st.num_fmt.as_str().to_string();
|
||||
ui.columns(2, |cols| {
|
||||
cols[0].label(egui::RichText::new("Category:").strong());
|
||||
cols[0].label(egui::RichText::new(lang.tr("Category:")).strong());
|
||||
for name in
|
||||
["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]
|
||||
{
|
||||
let c = gridcraft_engine::cmd::format::format_code_for(name).to_string();
|
||||
if cols[0].selectable_label(code == c, name).clicked() {
|
||||
if cols[0].selectable_label(code == c, lang.tr(name)).clicked() {
|
||||
code = c;
|
||||
}
|
||||
}
|
||||
cols[1].label(egui::RichText::new("Sample").strong());
|
||||
cols[1].label(egui::RichText::new(lang.tr("Sample")).strong());
|
||||
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
|
||||
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
|
||||
cols[1].add_space(8.0);
|
||||
cols[1].label("Type (custom format code):");
|
||||
cols[1].label(lang.tr("Type (custom format code):"));
|
||||
cols[1].text_edit_singleline(&mut code);
|
||||
for c in [
|
||||
"0",
|
||||
|
|
@ -1465,7 +1589,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
}
|
||||
"Alignment" => {
|
||||
use gridcraft_engine::model::{HAlign, VAlign};
|
||||
ui.label(egui::RichText::new("Horizontal").strong());
|
||||
ui.label(egui::RichText::new(lang.tr("Horizontal")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (h, n) in [
|
||||
(HAlign::General, "General"),
|
||||
|
|
@ -1477,10 +1601,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
(HAlign::CenterAcross, "Center Across Selection"),
|
||||
(HAlign::Distributed, "Distributed"),
|
||||
] {
|
||||
ui.radio_value(&mut st.align.h, h, n);
|
||||
ui.radio_value(&mut st.align.h, h, lang.tr(n));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new("Vertical").strong());
|
||||
ui.label(egui::RichText::new(lang.tr("Vertical")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (v, n) in [
|
||||
(VAlign::Top, "Top"),
|
||||
|
|
@ -1489,29 +1613,29 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
(VAlign::Justify, "Justify"),
|
||||
(VAlign::Distributed, "Distributed"),
|
||||
] {
|
||||
ui.radio_value(&mut st.align.v, v, n);
|
||||
ui.radio_value(&mut st.align.v, v, lang.tr(n));
|
||||
}
|
||||
});
|
||||
let mut indent = st.align.indent as f32;
|
||||
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text("Indent"));
|
||||
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent")));
|
||||
st.align.indent = indent as u8;
|
||||
let mut rot = if st.align.rotation == 255 { 0.0 } else { st.align.rotation as f32 };
|
||||
ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text("Orientation (degrees)"));
|
||||
ui.add(egui::Slider::new(&mut rot, -90.0..=90.0).text(lang.tr("Orientation (degrees)")));
|
||||
st.align.rotation = rot as i16;
|
||||
ui.checkbox(&mut st.align.wrap, "Wrap text");
|
||||
ui.checkbox(&mut st.align.shrink, "Shrink to fit");
|
||||
ui.checkbox(&mut st.align.wrap, lang.tr("Wrap text"));
|
||||
ui.checkbox(&mut st.align.shrink, lang.tr("Shrink to fit"));
|
||||
}
|
||||
"Font" => {
|
||||
egui::ComboBox::from_label("Font").selected_text(st.font.name.clone()).show_ui(ui, |ui| {
|
||||
egui::ComboBox::from_label(lang.tr("Font")).selected_text(st.font.name.clone()).show_ui(ui, |ui| {
|
||||
for f in crate::fonts::families() {
|
||||
ui.selectable_value(&mut st.font.name, f.clone(), f.clone());
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text("Size"));
|
||||
ui.add(egui::Slider::new(&mut st.font.size, 6.0..=72.0).text(lang.tr("Size")));
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut st.font.bold, "Bold");
|
||||
ui.checkbox(&mut st.font.italic, "Italic");
|
||||
ui.checkbox(&mut st.font.strike, "Strikethrough");
|
||||
ui.checkbox(&mut st.font.bold, lang.tr("Bold"));
|
||||
ui.checkbox(&mut st.font.italic, lang.tr("Italic"));
|
||||
ui.checkbox(&mut st.font.strike, lang.tr("Strikethrough"));
|
||||
});
|
||||
use gridcraft_engine::model::{Underline, VertAlign};
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -1522,17 +1646,17 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
(Underline::SingleAccounting, "Single Accounting"),
|
||||
(Underline::DoubleAccounting, "Double Accounting"),
|
||||
] {
|
||||
ui.radio_value(&mut st.font.underline, u, n);
|
||||
ui.radio_value(&mut st.font.underline, u, lang.tr(n));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Baseline, "Normal");
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Superscript, "Superscript");
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Subscript, "Subscript");
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Baseline, lang.tr("Normal"));
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Superscript, lang.tr("Superscript"));
|
||||
ui.radio_value(&mut st.font.vert, VertAlign::Subscript, lang.tr("Subscript"));
|
||||
});
|
||||
ui.label("Color:");
|
||||
ui.label(lang.tr("Color:"));
|
||||
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
|
||||
if let Some(c) = crate::widgets::color_palette(ui, &colors, "Automatic") {
|
||||
if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("Automatic")) {
|
||||
st.font.color =
|
||||
if c == "none" { gridcraft_engine::model::Color::Auto } else { gridcraft_engine::model::Color::from_hex(&c).unwrap_or_default() };
|
||||
}
|
||||
|
|
@ -1541,10 +1665,10 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
use gridcraft_engine::model::{BorderLine, BorderStyle};
|
||||
let line = BorderLine { style: BorderStyle::Thin, color: gridcraft_engine::model::Color::Auto };
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("None").clicked() {
|
||||
if ui.button(lang.tr("None")).clicked() {
|
||||
st.borders = Default::default();
|
||||
}
|
||||
if ui.button("Outline").clicked() {
|
||||
if ui.button(lang.tr("Outline")).clicked() {
|
||||
st.borders.top = line;
|
||||
st.borders.bottom = line;
|
||||
st.borders.left = line;
|
||||
|
|
@ -1559,6 +1683,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
("Diagonal ↘", &mut st.borders.diag_down),
|
||||
("Diagonal ↗", &mut st.borders.diag_up),
|
||||
] {
|
||||
let n = lang.tr(n);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{n}:"));
|
||||
for (s, label) in [
|
||||
|
|
@ -1570,6 +1695,7 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
(BorderStyle::Dotted, "dotted"),
|
||||
(BorderStyle::Double, "double"),
|
||||
] {
|
||||
let label = lang.tr(label);
|
||||
let response = ui.selectable_label(slot.style == s, label);
|
||||
response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
|
||||
|
|
@ -1584,8 +1710,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
}
|
||||
"Fill" => {
|
||||
let colors = app.session.active().map(|doc| doc.wb.theme.colors).unwrap_or_default();
|
||||
ui.label("Background Color:");
|
||||
if let Some(c) = crate::widgets::color_palette(ui, &colors, "No Color") {
|
||||
ui.label(lang.tr("Background Color:"));
|
||||
if let Some(c) = crate::widgets::color_palette(ui, &colors, lang.tr("No Color")) {
|
||||
st.fill = if c == "none" {
|
||||
Default::default()
|
||||
} else {
|
||||
|
|
@ -1594,11 +1720,13 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
|||
}
|
||||
}
|
||||
"Protection" => {
|
||||
ui.checkbox(&mut st.protection.locked, "Locked");
|
||||
ui.checkbox(&mut st.protection.hidden, "Hidden");
|
||||
ui.checkbox(&mut st.protection.locked, lang.tr("Locked"));
|
||||
ui.checkbox(&mut st.protection.hidden, lang.tr("Hidden"));
|
||||
ui.label(
|
||||
egui::RichText::new("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet).")
|
||||
.small(),
|
||||
egui::RichText::new(
|
||||
lang.tr("Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)."),
|
||||
)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -2534,6 +2662,51 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
|
|||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_validation_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_uses_one_source_and_does_not_submit_a_second_list() {
|
||||
let fields = data_validation_fields("list", "between");
|
||||
assert!(fields.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "Source:" })));
|
||||
assert!(!fields.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. } | Field::Choice { key: "operator", .. })));
|
||||
|
||||
let dialog = Dialog::form(
|
||||
"dataValidation",
|
||||
"Data Validation",
|
||||
"data.validation",
|
||||
fields,
|
||||
json!({"type": "list", "operator": "between", "formula1": "ABC,DEF", "formula2": "GHI,JKL", "dropdown": true}),
|
||||
);
|
||||
let params = build_params(&dialog);
|
||||
assert_eq!(params["formula1"], "ABC,DEF");
|
||||
assert!(params.get("formula2").is_none());
|
||||
assert!(params.get("operator").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_between_operators_show_and_submit_two_bounds() {
|
||||
let two = data_validation_fields("whole", "between");
|
||||
assert!(two.iter().any(|f| matches!(f, Field::Text { key: "formula2", label: "Maximum:" })));
|
||||
let one = data_validation_fields("date", "lessOrEqual");
|
||||
assert!(one.iter().any(|f| matches!(f, Field::Text { key: "formula1", label: "End date:" })));
|
||||
assert!(!one.iter().any(|f| matches!(f, Field::Text { key: "formula2", .. })));
|
||||
|
||||
let dialog = Dialog::form(
|
||||
"dataValidation",
|
||||
"Data Validation",
|
||||
"data.validation",
|
||||
one,
|
||||
json!({"type": "date", "operator": "lessOrEqual", "formula1": "2026-10-12", "formula2": "2030-01-01"}),
|
||||
);
|
||||
let params = build_params(&dialog);
|
||||
assert_eq!(params["operator"], "lessOrEqual");
|
||||
assert_eq!(params["formula1"], "2026-10-12");
|
||||
assert!(params.get("formula2").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,42 @@ pub const COL_HEADER_H: f32 = 21.0;
|
|||
/// which matches desktop spreadsheet density (a default 20 px row is 16 pt tall).
|
||||
pub const DISPLAY_SCALE: f32 = 0.8;
|
||||
|
||||
/// The zoom percentages a Ctrl+wheel notch steps between: four per doubling (about 20% apart),
|
||||
/// so 25, 50, 100, 200 and 400 are on it. Excel steps 15 points a notch instead; the status bar's
|
||||
/// Zoom In / Zoom Out buttons keep their 10% steps.
|
||||
pub const WHEEL_ZOOM_LADDER: [u16; 22] = [10, 12, 15, 18, 21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400];
|
||||
|
||||
/// Seconds without Ctrl+wheel input after which part of a notch turned so far is forgotten.
|
||||
const WHEEL_ZOOM_IDLE: f64 = 0.75;
|
||||
|
||||
/// The zoom one Ctrl+wheel notch goes to from `zoom`: the next step on [`WHEEL_ZOOM_LADDER`] in
|
||||
/// that direction, skipping a step less than 5% away (121% out goes to 100%, not 120%), but never
|
||||
/// 100% or the 10% / 400% limits (99% or 101% towards 100% goes to 100%).
|
||||
pub fn wheel_zoom_step(zoom: u16, zoom_in: bool) -> u16 {
|
||||
let zoom = zoom.clamp(10, 400);
|
||||
let z = u32::from(zoom);
|
||||
let far = |l: u32| if zoom_in { l * 100 >= z * 105 } else { l * 105 <= z * 100 };
|
||||
let mut ahead = WHEEL_ZOOM_LADDER.iter().copied().filter(|&l| if zoom_in { l > zoom } else { l < zoom }).collect::<Vec<_>>();
|
||||
if !zoom_in {
|
||||
ahead.reverse();
|
||||
}
|
||||
let limit = ahead.last().copied().unwrap_or(zoom);
|
||||
ahead.into_iter().find(|&l| l == 100 || far(u32::from(l))).unwrap_or(limit)
|
||||
}
|
||||
|
||||
/// One frame of the zoom gliding from `from` to `to` percent: the share `t` (0–1) of the way,
|
||||
/// measured as a ratio so zooming in and out feel alike, and at least one percent, so it arrives.
|
||||
pub fn zoom_glide_step(from: u16, to: u16, t: f32) -> u16 {
|
||||
let (f, g) = (f32::from(from.max(1)), f32::from(to.max(1)));
|
||||
let next = (f * (g / f).powf(t.clamp(0.0, 1.0))).round();
|
||||
let next = if next.is_finite() { next.clamp(f32::from(from.min(to)), f32::from(from.max(to))) as u16 } else { to };
|
||||
match (next == from, from.cmp(&to)) {
|
||||
(true, std::cmp::Ordering::Less) => from + 1,
|
||||
(true, std::cmp::Ordering::Greater) => from - 1,
|
||||
_ => next,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub enum Drag {
|
||||
#[default]
|
||||
|
|
@ -103,10 +139,42 @@ pub struct GridState {
|
|||
pub paper: Option<crate::paper_view::PaperCache>,
|
||||
/// Header or footer being edited in place (Page Layout view).
|
||||
pub hf_edit: Option<crate::paper_view::HfEdit>,
|
||||
/// A page margin being dragged on a ruler (Page Layout view); committed on release.
|
||||
pub ruler_drag: Option<crate::paper_view::RulerDrag>,
|
||||
/// Insert › Header & Footer: start editing the first page's header once Page Layout view is up.
|
||||
pub hf_start: bool,
|
||||
/// The grid geometry of the last frame (hit-testing for agents and tests).
|
||||
pub geo: Option<Geo>,
|
||||
/// The horizontal scrollbar's track in the sheet tab bar (hit-testing for tests).
|
||||
pub hscroll: Option<Rect>,
|
||||
/// Ctrl+wheel zoom in progress (see [`WheelZoom`]).
|
||||
pub wheel_zoom: Option<WheelZoom>,
|
||||
}
|
||||
|
||||
/// Ctrl+wheel zoom for one (doc uid, sheet). egui eases a notch in over several frames and
|
||||
/// fine-grained wheels send parts of one, so the turning is counted in notches, and each whole
|
||||
/// notch steps [`WHEEL_ZOOM_LADDER`]. With animations on the zoom then glides to that step.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WheelZoom {
|
||||
pub place: (u64, usize),
|
||||
/// Turned so far towards the next step, in notches (+ in, − out).
|
||||
pub notches: f32,
|
||||
/// When `notches` last changed.
|
||||
pub time: f64,
|
||||
/// The step the zoom is gliding to, and the percentage set last on the way.
|
||||
pub glide: Option<(u16, u16)>,
|
||||
}
|
||||
|
||||
impl WheelZoom {
|
||||
/// The state for `place` showing `zoom` percent: fresh for another document or sheet, and
|
||||
/// without its glide if the zoom was changed some other way since the glide set it.
|
||||
fn at(app: &SheetApp, place: (u64, usize), zoom: u16, now: f64) -> WheelZoom {
|
||||
let mut w = app.grid.wheel_zoom.filter(|w| w.place == place).unwrap_or(WheelZoom { place, notches: 0.0, time: now, glide: None });
|
||||
if w.glide.is_some_and(|(_, shown)| shown != zoom) {
|
||||
w.glide = None;
|
||||
}
|
||||
w
|
||||
}
|
||||
}
|
||||
|
||||
impl GridState {
|
||||
|
|
@ -233,6 +301,13 @@ impl Geo {
|
|||
None => (sh.row_top(row) - sh.row_top(self.fr)) as f32,
|
||||
}
|
||||
}
|
||||
/// Scroll offset (in `scroll` units) at which `col` reaches the left of the scrolled area.
|
||||
pub fn scroll_left_of(&self, sh: &Sheet, col: u32) -> f32 {
|
||||
match &self.paper {
|
||||
Some(pg) => pg.col_x(col) as f32,
|
||||
None => (sh.col_left(col) - sh.col_left(self.fc)) as f32,
|
||||
}
|
||||
}
|
||||
/// Screen x for a logical x (and back): identity unless the sheet is right-to-left.
|
||||
pub fn mirror_x(&self, x: f32) -> f32 {
|
||||
if self.rtl { self.rect.left() + self.rect.right() - x } else { x }
|
||||
|
|
@ -371,6 +446,15 @@ fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds.
|
||||
fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 {
|
||||
let mut end = start;
|
||||
while end < limit && end + 1 < MAX_COLS && continues(end + 1) {
|
||||
end += 1;
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option<CfLook>) {
|
||||
let mut style = wb.styles.get(sh.style_id(c)).clone();
|
||||
if let Some(t) = sh.table_at(c) {
|
||||
|
|
@ -491,11 +575,17 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
// Page Layout view: pages on a desk, rulers above and left of the headings.
|
||||
let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout;
|
||||
let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 };
|
||||
let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max);
|
||||
// Right-to-left sheets carry the vertical ruler on the right, beside the row headings.
|
||||
let grid_rect = if rtl {
|
||||
Rect::from_min_max(pos2(rect.left(), (rect.top() + ruler).min(rect.bottom())), pos2((rect.right() - ruler).max(rect.left()), rect.bottom()))
|
||||
} else {
|
||||
Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max)
|
||||
};
|
||||
let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo {
|
||||
if paper {
|
||||
let probe = Geo::new(sh, grid_rect, scroll);
|
||||
if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) {
|
||||
let g = crate::paper_view::with_dragged_margin(app, &wb, si, g);
|
||||
return Geo::paged(sh, grid_rect, scroll, g);
|
||||
}
|
||||
}
|
||||
|
|
@ -505,8 +595,34 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
let (scroll_delta, zoom_delta, modifiers, now) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.time));
|
||||
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
|
||||
if zoom_delta != 1.0 {
|
||||
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
// egui's zoom factor is e^(speed × points); one notch is `line_scroll_speed` points.
|
||||
let notch = ui.ctx().options(|o| o.input_options.scroll_zoom_speed * o.input_options.line_scroll_speed);
|
||||
let turned = zoom_delta.ln() / notch;
|
||||
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
|
||||
if now - zooming.time > WHEEL_ZOOM_IDLE {
|
||||
zooming.notches = 0.0;
|
||||
}
|
||||
if turned.is_finite() {
|
||||
zooming.notches = (zooming.notches + turned).clamp(-32.0, 32.0);
|
||||
}
|
||||
zooming.time = now;
|
||||
// A notch steps once 60% of it has turned, and the rest of it brings `notches` back to
|
||||
// about 0. (Past half, so what's left after a step can't step back.) Steps count from
|
||||
// where a glide is going, not from where it has got to.
|
||||
let from = zooming.glide.map_or(sh.zoom, |(to, _)| to);
|
||||
let mut z = from;
|
||||
while zooming.notches >= 0.6 {
|
||||
z = wheel_zoom_step(z, true);
|
||||
zooming.notches -= 1.0;
|
||||
}
|
||||
while zooming.notches <= -0.6 {
|
||||
z = wheel_zoom_step(z, false);
|
||||
zooming.notches += 1.0;
|
||||
}
|
||||
if z != from {
|
||||
zooming.glide = Some((z, sh.zoom));
|
||||
}
|
||||
app.grid.wheel_zoom = Some(zooming);
|
||||
} else if scroll_delta != egui::Vec2::ZERO {
|
||||
let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
|
||||
let mut d = scroll_delta;
|
||||
|
|
@ -521,6 +637,22 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
|||
view.scroll -= d / z;
|
||||
}
|
||||
}
|
||||
// The zoom glides to its step like egui eases a wheel notch (90% of the way in 0.1 s), or goes
|
||||
// there at once without animations (as for the selection outline below: View › Animations
|
||||
// off, egui's off in the headless harness, or a screenshot on its way).
|
||||
let mut zooming = WheelZoom::at(app, place, sh.zoom, now);
|
||||
if let Some((to, shown)) = zooming.glide {
|
||||
let glides = app.ui.animations && ui.ctx().global_style().animation_time > 0.0 && !app.shots.in_flight();
|
||||
let share = egui::emath::exponential_smooth_factor(0.9, 0.1, ui.input(|i| i.stable_dt).min(0.1));
|
||||
let z = if glides { zoom_glide_step(shown, to, share) } else { to };
|
||||
let _ = app.session.run("view.zoom", json!({"percent": z}));
|
||||
zooming.glide = (z != to).then_some((to, z));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// Store a glide's progress, or that it stopped.
|
||||
if app.grid.wheel_zoom.is_some_and(|w| w.glide.is_some()) {
|
||||
app.grid.wheel_zoom = Some(zooming);
|
||||
}
|
||||
view.scroll = view.scroll.max(egui::Vec2::ZERO);
|
||||
let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32);
|
||||
if paper {
|
||||
|
|
@ -734,6 +866,63 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
|
|||
}
|
||||
}
|
||||
|
||||
/// The horizontal counterpart of `vertical_scrollbar`, drawn in the sheet tab bar as in Excel. The tab
|
||||
/// bar is laid out before the grid, so this reads the last frame's geometry and leaves clamping the
|
||||
/// far end to the grid, which runs later in the same frame.
|
||||
pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: Rect, t: &Tokens) {
|
||||
app.grid.hscroll = Some(track);
|
||||
if track.height() <= 0.0 || track.width() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let Some(geo) = app.grid.geo.clone() else { return };
|
||||
let Some(wb) = app.session.active().map(|d| d.wb.clone()) else { return };
|
||||
let Some(sh) = wb.active() else { return };
|
||||
let visible = ((geo.cells.width() - geo.frozen_w) / geo.z).max(0.0);
|
||||
let scroll = app.view().scroll.x;
|
||||
let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1);
|
||||
let content = geo.scroll_left_of(sh, last);
|
||||
let extent = (content - visible).max(scroll).max(0.0);
|
||||
let id = ui.id().with("hscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(drag_id, extent));
|
||||
}
|
||||
let extent = if resp.dragged() { ui.ctx().data(|d| d.get_temp::<f32>(drag_id)).unwrap_or(extent) } else { extent };
|
||||
let thumb_w = (track.width() * visible / (extent + visible).max(1.0)).clamp(24.0_f32.min(track.width()), track.width());
|
||||
let travel = track.width() - thumb_w;
|
||||
let offset = if extent > 0.0 { travel * (scroll / extent).clamp(0.0, 1.0) } else { 0.0 };
|
||||
// A right-to-left sheet scrolls towards the left, so its thumb starts at the right end.
|
||||
let dir = if geo.rtl { -1.0 } else { 1.0 };
|
||||
let thumb_left = if geo.rtl { track.right() - thumb_w - offset } else { track.left() + offset };
|
||||
let thumb = Rect::from_min_size(pos2(thumb_left, track.top() + 3.0), vec2(thumb_w, (track.height() - 6.0).max(0.0)));
|
||||
ui.painter().rect_filled(track, 0.0, t.tab_bar);
|
||||
ui.painter().rect_filled(thumb, 4.0, if resp.hovered() || resp.dragged() { t.text_dim } else { t.text_disabled });
|
||||
let mut next = scroll;
|
||||
if resp.dragged() && travel > 0.0 {
|
||||
next += dir * resp.drag_delta().x / travel * extent;
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
if p.x < thumb.left() {
|
||||
next -= dir * visible;
|
||||
} else if p.x > thumb.right() {
|
||||
next += dir * visible;
|
||||
}
|
||||
}
|
||||
if resp.hovered() {
|
||||
let d = ui.input(|i| i.smooth_scroll_delta);
|
||||
next -= (if d.x != 0.0 { dir * d.x } else { d.y }) / geo.z;
|
||||
}
|
||||
next = next.max(0.0);
|
||||
if next != scroll {
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.scroll.x = next;
|
||||
}
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn wrapped_cell_text_y(valign: VAlign, rect: Rect, gh: f32, z: f32) -> f32 {
|
||||
let pad_v = 1.5 * z;
|
||||
let y_top = rect.top() + pad_v;
|
||||
|
|
@ -1039,17 +1228,24 @@ pub(crate) fn paint_quadrant(
|
|||
galley = p.layout_no_wrap(s, font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it.
|
||||
let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() {
|
||||
center_across_end(c.col, c1 + 20, |col| {
|
||||
let n = CellRef::new(c.row, col);
|
||||
empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross
|
||||
})
|
||||
} else {
|
||||
c.col
|
||||
};
|
||||
// Overflow into empty neighbours for text (not upright text, whose width is its line height).
|
||||
let mut clip = *rect;
|
||||
if filter_space == 0.0
|
||||
&& !numeric
|
||||
&& !matches!(v, Value::Number(_))
|
||||
&& st.align.rotation.abs() != 90
|
||||
&& w > rect.width() - 2.0 * pad
|
||||
&& in_merge(c).is_none()
|
||||
{
|
||||
let overflows = !numeric && !matches!(v, Value::Number(_)) && st.align.rotation.abs() != 90 && w > rect.width() - 2.0 * pad;
|
||||
if filter_space == 0.0 && (overflows || across_end > c.col) && in_merge(c).is_none() {
|
||||
let mut right = rect.right();
|
||||
let mut left = rect.left();
|
||||
if across_end > c.col {
|
||||
right = geo.x(sh, across_end + 1);
|
||||
}
|
||||
if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {
|
||||
let mut cc = c.col + 1;
|
||||
while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {
|
||||
|
|
@ -1079,7 +1275,8 @@ pub(crate) fn paint_quadrant(
|
|||
}
|
||||
}
|
||||
let x = match halign {
|
||||
HAlign::Center | HAlign::CenterAcross | HAlign::Distributed => rect.center().x - w / 2.0,
|
||||
HAlign::CenterAcross => clip.center().x - w / 2.0,
|
||||
HAlign::Center | HAlign::Distributed => rect.center().x - w / 2.0,
|
||||
HAlign::Right => rect.right() - pad - w - indent,
|
||||
_ => text_left + pad + indent,
|
||||
};
|
||||
|
|
@ -2327,15 +2524,18 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
|
||||
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
|
||||
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
|
||||
pub fn follow_link(app: &mut SheetApp, target: &str) {
|
||||
if target.contains("://") || target.starts_with("mailto:") {
|
||||
if let Some(f) = &app.services.open_url {
|
||||
f(target);
|
||||
use gridcraft_engine::links::{LinkKind, classify};
|
||||
match classify(target) {
|
||||
LinkKind::Internal(r) => {
|
||||
let _ = app.session.run("edit.goTo", json!({"reference": r.trim_start_matches('#')}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
} else {
|
||||
let t = target.trim_start_matches('#');
|
||||
let _ = app.session.run("edit.goTo", json!({"reference": t}));
|
||||
app.grid.ensure_visible = true;
|
||||
LinkKind::External(url) => app.open_dialog("openLink", json!({"url": url})),
|
||||
LinkKind::Refused(why) => app.message = Some(("Hyperlink".into(), why)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2445,6 +2645,12 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
|||
let _ = app.session.run("selection.set", json!({"cell": CellRef::new(a.row, 0).a1()}));
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
// Excel: Enter while a copied range is marked pastes it and ends copy mode.
|
||||
Key::Enter if !m.alt && !m.ctrl && !shift && !cmd && app.session.clipboard.is_some() => {
|
||||
app.run_or_alert("edit.paste", json!({}));
|
||||
app.session.clipboard = None;
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
Key::Enter => {
|
||||
if m.alt || m.ctrl {
|
||||
app.begin_edit(None, false);
|
||||
|
|
@ -3115,6 +3321,76 @@ mod selection_fill_tests {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() {
|
||||
for (from, zoom_in, to) in [
|
||||
(100, true, 120),
|
||||
(120, false, 100),
|
||||
(100, false, 85),
|
||||
// Between steps, the next one each way (Excel: 112% goes to 115% or 100%).
|
||||
(112, true, 120),
|
||||
(112, false, 100),
|
||||
// A step less than 5% away is skipped…
|
||||
(121, false, 100),
|
||||
(86, false, 70),
|
||||
(139, true, 170),
|
||||
// …but never 100% or a limit.
|
||||
(99, true, 100),
|
||||
(101, false, 100),
|
||||
(96, true, 100),
|
||||
(385, true, 400),
|
||||
(11, false, 10),
|
||||
(400, true, 400),
|
||||
(10, false, 10),
|
||||
// Out-of-range zooms (from a file) come into range first.
|
||||
(5, true, 12),
|
||||
(1000, false, 340),
|
||||
(0, false, 10),
|
||||
(u16::MAX, true, 400),
|
||||
] {
|
||||
assert_eq!(wheel_zoom_step(from, zoom_in), to, "{from}% {}", if zoom_in { "in" } else { "out" });
|
||||
}
|
||||
// Every step on the ladder is 15–25% from the next, and a notch in then out returns.
|
||||
// (The glide between steps is `zoom_glide_moves_by_ratio_and_always_arrives`.)
|
||||
for w in WHEEL_ZOOM_LADDER.windows(2) {
|
||||
let ratio = f32::from(w[1]) / f32::from(w[0]);
|
||||
assert!((1.15..=1.25).contains(&ratio), "{w:?}");
|
||||
assert_eq!(wheel_zoom_step(w[0], true), w[1]);
|
||||
assert_eq!(wheel_zoom_step(w[1], false), w[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_glide_moves_by_ratio_and_always_arrives() {
|
||||
// A third of the way per frame, as at 60 frames a second.
|
||||
for (from, to) in [(100, 120), (120, 100), (10, 12), (340, 400), (400, 10), (85, 100)] {
|
||||
let (mut z, mut frames) = (from, 0);
|
||||
while z != to {
|
||||
let next = zoom_glide_step(z, to, 0.32);
|
||||
assert!(next != z && next.abs_diff(to) < z.abs_diff(to), "{from} → {to}: {z} then {next}");
|
||||
z = next;
|
||||
frames += 1;
|
||||
assert!(frames < 40, "{from} → {to} arrives");
|
||||
}
|
||||
assert!(frames >= 2, "{from} → {to} glides over more than one frame ({frames})");
|
||||
}
|
||||
assert_eq!(zoom_glide_step(100, 120, 0.32), 106, "a third of the way by ratio");
|
||||
assert_eq!(zoom_glide_step(120, 100, 0.32), 113);
|
||||
// No progress asked or nonsense: still a percent closer, or straight there.
|
||||
assert_eq!(zoom_glide_step(100, 120, 0.0), 101);
|
||||
assert_eq!(zoom_glide_step(120, 100, f32::NAN), 100);
|
||||
assert_eq!(zoom_glide_step(100, 120, 5.0), 120);
|
||||
assert_eq!(zoom_glide_step(120, 120, 0.5), 120);
|
||||
assert_eq!(zoom_glide_step(0, 10, 0.5), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_run_stops_at_first_non_continuing_column() {
|
||||
assert_eq!(center_across_end(0, 20, |c| c <= 2), 2);
|
||||
assert_eq!(center_across_end(0, 20, |_| false), 0);
|
||||
assert_eq!(center_across_end(0, 1, |_| true), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_menu_uses_the_table_whose_header_was_clicked() {
|
||||
use gridcraft_engine::model::Table;
|
||||
|
|
|
|||
|
|
@ -296,6 +296,12 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Select\nObjects", "オブジェクトの選択"),
|
||||
("Themes", "テーマ"),
|
||||
("Margins", "余白"),
|
||||
("Left Margin", "左余白"),
|
||||
("Right Margin", "右余白"),
|
||||
("Top Margin", "上余白"),
|
||||
("Bottom Margin", "下余白"),
|
||||
("Header Margin", "ヘッダーの余白"),
|
||||
("Footer Margin", "フッターの余白"),
|
||||
("Size", "サイズ"),
|
||||
("Print\nArea", "印刷範囲"),
|
||||
("Set Print Area", "印刷範囲の設定"),
|
||||
|
|
@ -517,6 +523,16 @@ const JAPANESE: &[(&str, &str)] = &[
|
|||
("Recent", "最近使ったアイテム"),
|
||||
("No recent workbooks yet.", "最近使ったブックはまだありません。"),
|
||||
("More workbooks", "その他のブック"),
|
||||
("Modified", "更新日時"),
|
||||
("Not available", "利用できません"),
|
||||
("Just now", "たった今"),
|
||||
("Yesterday", "昨日"),
|
||||
("{n} min ago", "{n} 分前"),
|
||||
("{n} h ago", "{n} 時間前"),
|
||||
("{n} days ago", "{n} 日前"),
|
||||
("{n} weeks ago", "{n} 週間前"),
|
||||
("{n} months ago", "{n} か月前"),
|
||||
("{n} years ago", "{n} 年前"),
|
||||
("Browse…", "参照…"),
|
||||
("Not saved yet", "まだ保存されていません"),
|
||||
("Location", "場所"),
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Vybrat\nobjekty",
|
||||
"Themes" => "Motivy",
|
||||
"Margins" => "Okraje",
|
||||
"Left Margin" => "Levý okraj",
|
||||
"Right Margin" => "Pravý okraj",
|
||||
"Top Margin" => "Horní okraj",
|
||||
"Bottom Margin" => "Dolní okraj",
|
||||
"Header Margin" => "Okraj záhlaví",
|
||||
"Footer Margin" => "Okraj zápatí",
|
||||
"Size" => "Velikost",
|
||||
"Print\nArea" => "Oblast\ntisku",
|
||||
"Set Print Area" => "Nastavit oblast tisku",
|
||||
|
|
@ -256,6 +262,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Naposledy použité",
|
||||
"No recent workbooks yet." => "Zatím žádné naposledy použité sešity.",
|
||||
"More workbooks" => "Další sešity",
|
||||
"Modified" => "Změněno",
|
||||
"Not available" => "Nedostupné",
|
||||
"Just now" => "Právě teď",
|
||||
"Yesterday" => "Včera",
|
||||
"{n} min ago" => "před {n} min",
|
||||
"{n} h ago" => "před {n} h",
|
||||
"{n} days ago" => "před {n} dny",
|
||||
"{n} weeks ago" => "před {n} týdny",
|
||||
"{n} months ago" => "před {n} měsíci",
|
||||
"{n} years ago" => "před {n} lety",
|
||||
"Browse…" => "Procházet…",
|
||||
"Not saved yet" => "Zatím neuloženo",
|
||||
"Location" => "Umístění",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Objekte\nauswählen",
|
||||
"Themes" => "Designs",
|
||||
"Margins" => "Seitenränder",
|
||||
"Left Margin" => "Linker Seitenrand",
|
||||
"Right Margin" => "Rechter Seitenrand",
|
||||
"Top Margin" => "Oberer Seitenrand",
|
||||
"Bottom Margin" => "Unterer Seitenrand",
|
||||
"Header Margin" => "Kopfzeilenabstand",
|
||||
"Footer Margin" => "Fußzeilenabstand",
|
||||
"Size" => "Größe",
|
||||
"Print\nArea" => "Druck-\nbereich",
|
||||
"Set Print Area" => "Druckbereich festlegen",
|
||||
|
|
@ -254,6 +260,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Zuletzt verwendet",
|
||||
"No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.",
|
||||
"More workbooks" => "Weitere Arbeitsmappen",
|
||||
"Modified" => "Geändert",
|
||||
"Not available" => "Nicht verfügbar",
|
||||
"Just now" => "Gerade eben",
|
||||
"Yesterday" => "Gestern",
|
||||
"{n} min ago" => "vor {n} Min.",
|
||||
"{n} h ago" => "vor {n} Std.",
|
||||
"{n} days ago" => "vor {n} Tagen",
|
||||
"{n} weeks ago" => "vor {n} Wochen",
|
||||
"{n} months ago" => "vor {n} Monaten",
|
||||
"{n} years ago" => "vor {n} Jahren",
|
||||
"Browse…" => "Durchsuchen…",
|
||||
"Not saved yet" => "Noch nicht gespeichert",
|
||||
"Location" => "Speicherort",
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Seleccionar\nobjetos",
|
||||
"Themes" => "Temas",
|
||||
"Margins" => "Márgenes",
|
||||
"Left Margin" => "Margen izquierdo",
|
||||
"Right Margin" => "Margen derecho",
|
||||
"Top Margin" => "Margen superior",
|
||||
"Bottom Margin" => "Margen inferior",
|
||||
"Header Margin" => "Margen del encabezado",
|
||||
"Footer Margin" => "Margen del pie de página",
|
||||
"Size" => "Tamaño",
|
||||
"Print\nArea" => "Área de\nimpresión",
|
||||
"Set Print Area" => "Establecer área de impresión",
|
||||
|
|
@ -255,6 +261,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Recientes",
|
||||
"No recent workbooks yet." => "Todavía no hay libros recientes.",
|
||||
"More workbooks" => "Más libros",
|
||||
"Modified" => "Modificado",
|
||||
"Not available" => "No disponible",
|
||||
"Just now" => "Ahora mismo",
|
||||
"Yesterday" => "Ayer",
|
||||
"{n} min ago" => "hace {n} min",
|
||||
"{n} h ago" => "hace {n} h",
|
||||
"{n} days ago" => "hace {n} días",
|
||||
"{n} weeks ago" => "hace {n} semanas",
|
||||
"{n} months ago" => "hace {n} meses",
|
||||
"{n} years ago" => "hace {n} años",
|
||||
"Browse…" => "Examinar…",
|
||||
"Not saved yet" => "Aún no se ha guardado",
|
||||
"Location" => "Ubicación",
|
||||
|
|
|
|||
|
|
@ -124,6 +124,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Sélectionner les\nobjets",
|
||||
"Themes" => "Thèmes",
|
||||
"Margins" => "Marges",
|
||||
"Left Margin" => "Marge gauche",
|
||||
"Right Margin" => "Marge droite",
|
||||
"Top Margin" => "Marge haute",
|
||||
"Bottom Margin" => "Marge basse",
|
||||
"Header Margin" => "Marge de l'en-tête",
|
||||
"Footer Margin" => "Marge du pied de page",
|
||||
"Size" => "Taille",
|
||||
"Print\nArea" => "Zone\nd'impression",
|
||||
"Set Print Area" => "Définir la zone d'impression",
|
||||
|
|
@ -340,6 +346,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Récent",
|
||||
"No recent workbooks yet." => "Aucun classeur récent pour le moment.",
|
||||
"More workbooks" => "Plus de classeurs",
|
||||
"Modified" => "Modifié",
|
||||
"Not available" => "Indisponible",
|
||||
"Just now" => "À l'instant",
|
||||
"Yesterday" => "Hier",
|
||||
"{n} min ago" => "il y a {n} min",
|
||||
"{n} h ago" => "il y a {n} h",
|
||||
"{n} days ago" => "il y a {n} jours",
|
||||
"{n} weeks ago" => "il y a {n} semaines",
|
||||
"{n} months ago" => "il y a {n} mois",
|
||||
"{n} years ago" => "il y a {n} ans",
|
||||
"Browse…" => "Parcourir…",
|
||||
"Not saved yet" => "Pas encore enregistré",
|
||||
"Location" => "Emplacement",
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "개체\n선택",
|
||||
"Themes" => "테마",
|
||||
"Margins" => "여백",
|
||||
"Left Margin" => "왼쪽 여백",
|
||||
"Right Margin" => "오른쪽 여백",
|
||||
"Top Margin" => "위쪽 여백",
|
||||
"Bottom Margin" => "아래쪽 여백",
|
||||
"Header Margin" => "머리글 여백",
|
||||
"Footer Margin" => "바닥글 여백",
|
||||
"Size" => "용지 크기",
|
||||
"Print\nArea" => "인쇄\n영역",
|
||||
"Set Print Area" => "인쇄 영역 설정",
|
||||
|
|
@ -342,6 +348,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "최근 항목",
|
||||
"No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.",
|
||||
"More workbooks" => "더 많은 통합 문서",
|
||||
"Modified" => "수정한 날짜",
|
||||
"Not available" => "사용할 수 없음",
|
||||
"Just now" => "방금",
|
||||
"Yesterday" => "어제",
|
||||
"{n} min ago" => "{n}분 전",
|
||||
"{n} h ago" => "{n}시간 전",
|
||||
"{n} days ago" => "{n}일 전",
|
||||
"{n} weeks ago" => "{n}주 전",
|
||||
"{n} months ago" => "{n}개월 전",
|
||||
"{n} years ago" => "{n}년 전",
|
||||
"Browse…" => "찾아보기…",
|
||||
"Not saved yet" => "아직 저장되지 않음",
|
||||
"Location" => "위치",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Selecionar\nObjetos",
|
||||
"Themes" => "Temas",
|
||||
"Margins" => "Margens",
|
||||
"Left Margin" => "Margem esquerda",
|
||||
"Right Margin" => "Margem direita",
|
||||
"Top Margin" => "Margem superior",
|
||||
"Bottom Margin" => "Margem inferior",
|
||||
"Header Margin" => "Margem do cabeçalho",
|
||||
"Footer Margin" => "Margem do rodapé",
|
||||
"Size" => "Tamanho",
|
||||
"Print\nArea" => "Área de\nImpressão",
|
||||
"Set Print Area" => "Definir Área de Impressão",
|
||||
|
|
@ -343,6 +349,15 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Recentes",
|
||||
"No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.",
|
||||
"More workbooks" => "Mais pastas de trabalho",
|
||||
"Modified" => "Modificado",
|
||||
"Not available" => "Indisponível",
|
||||
"Just now" => "Agora mesmo",
|
||||
"{n} min ago" => "há {n} min",
|
||||
"{n} h ago" => "há {n} h",
|
||||
"{n} days ago" => "há {n} dias",
|
||||
"{n} weeks ago" => "há {n} semanas",
|
||||
"{n} months ago" => "há {n} meses",
|
||||
"{n} years ago" => "há {n} anos",
|
||||
"Browse…" => "Procurar…",
|
||||
"Not saved yet" => "Ainda não salvo",
|
||||
"Location" => "Local",
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "Выбрать\nобъекты",
|
||||
"Themes" => "Темы",
|
||||
"Margins" => "Поля",
|
||||
"Left Margin" => "Левое поле",
|
||||
"Right Margin" => "Правое поле",
|
||||
"Top Margin" => "Верхнее поле",
|
||||
"Bottom Margin" => "Нижнее поле",
|
||||
"Header Margin" => "Поле верхнего колонтитула",
|
||||
"Footer Margin" => "Поле нижнего колонтитула",
|
||||
"Size" => "Размер",
|
||||
"Print\nArea" => "Область\nпечати",
|
||||
"Set Print Area" => "Задать область печати",
|
||||
|
|
@ -342,6 +348,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Последние",
|
||||
"No recent workbooks yet." => "Недавних книг пока нет.",
|
||||
"More workbooks" => "Другие книги",
|
||||
"Modified" => "Изменён",
|
||||
"Not available" => "Недоступен",
|
||||
"Just now" => "Только что",
|
||||
"Yesterday" => "Вчера",
|
||||
"{n} min ago" => "{n} мин назад",
|
||||
"{n} h ago" => "{n} ч назад",
|
||||
"{n} days ago" => "{n} дн. назад",
|
||||
"{n} weeks ago" => "{n} нед. назад",
|
||||
"{n} months ago" => "{n} мес. назад",
|
||||
"{n} years ago" => "{n} г. назад",
|
||||
"Browse…" => "Обзор…",
|
||||
"Not saved yet" => "Ещё не сохранено",
|
||||
"Location" => "Расположение",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "Nedavno",
|
||||
"No recent workbooks yet." => "Još nema nedavnih radnih svezaka.",
|
||||
"More workbooks" => "Više radnih svezaka",
|
||||
"Modified" => "Izmenjeno",
|
||||
"Not available" => "Nije dostupno",
|
||||
"Just now" => "Upravo sada",
|
||||
"Yesterday" => "Juče",
|
||||
"{n} min ago" => "pre {n} min",
|
||||
"{n} h ago" => "pre {n} h",
|
||||
"{n} days ago" => "pre {n} dana",
|
||||
"{n} weeks ago" => "pre {n} ned.",
|
||||
"{n} months ago" => "pre {n} mes.",
|
||||
"{n} years ago" => "pre {n} god.",
|
||||
"Browse…" => "Pregledaj…",
|
||||
"Not saved yet" => "Još nije sačuvano",
|
||||
"Location" => "Lokacija",
|
||||
|
|
@ -391,6 +401,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Workbook calculation" => "Izračunavanje radne sveske",
|
||||
"Enable iterative calculation" => "Omogući iterativno izračunavanje",
|
||||
"Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.",
|
||||
"Left Margin" => "Leva margina",
|
||||
"Right Margin" => "Desna margina",
|
||||
"Top Margin" => "Gornja margina",
|
||||
"Bottom Margin" => "Donja margina",
|
||||
"Header Margin" => "Margina zaglavlja",
|
||||
"Footer Margin" => "Margina podnožja",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Select\nObjects" => "选择\n对象",
|
||||
"Themes" => "主题",
|
||||
"Margins" => "页边距",
|
||||
"Left Margin" => "左边距",
|
||||
"Right Margin" => "右边距",
|
||||
"Top Margin" => "上边距",
|
||||
"Bottom Margin" => "下边距",
|
||||
"Header Margin" => "页眉边距",
|
||||
"Footer Margin" => "页脚边距",
|
||||
"Size" => "纸张大小",
|
||||
"Print\nArea" => "打印\n区域",
|
||||
"Set Print Area" => "设置打印区域",
|
||||
|
|
@ -340,6 +346,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
|||
"Recent" => "最近",
|
||||
"No recent workbooks yet." => "还没有最近使用的工作簿。",
|
||||
"More workbooks" => "更多工作簿",
|
||||
"Modified" => "修改时间",
|
||||
"Not available" => "不可用",
|
||||
"Just now" => "刚刚",
|
||||
"Yesterday" => "昨天",
|
||||
"{n} min ago" => "{n} 分钟前",
|
||||
"{n} h ago" => "{n} 小时前",
|
||||
"{n} days ago" => "{n} 天前",
|
||||
"{n} weeks ago" => "{n} 周前",
|
||||
"{n} months ago" => "{n} 个月前",
|
||||
"{n} years ago" => "{n} 年前",
|
||||
"Browse…" => "浏览…",
|
||||
"Not saved yet" => "尚未保存",
|
||||
"Location" => "位置",
|
||||
|
|
|
|||
|
|
@ -221,6 +221,9 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
|
|||
Shortcut::DropDown => {
|
||||
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
|
||||
app.grid.list_picker = Some(active);
|
||||
} else if sh.has_filter_button(active) {
|
||||
let at = geo.mirror_pos(crate::grid::filter_button_rect(geo, sh, active).center());
|
||||
app.grid.filter_menu = Some((active.col, active.row, at));
|
||||
}
|
||||
}
|
||||
Shortcut::Zoom(step) => {
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
|
|||
commit(app);
|
||||
}
|
||||
let Some(pg) = geo.paper.clone() else { return };
|
||||
ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet);
|
||||
if std::mem::take(&mut app.grid.hf_start) {
|
||||
begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 });
|
||||
}
|
||||
|
|
@ -510,6 +511,14 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
|
|||
app.grid.hf_edit = Some(e);
|
||||
}
|
||||
|
||||
/// Screen rectangles of the horizontal and vertical rulers. Right-to-left sheets mirror both:
|
||||
/// the vertical ruler sits on the right, beside the row headings.
|
||||
fn ruler_rects(outer: Rect, geo: &Geo) -> (Rect, Rect) {
|
||||
let hr = geo.mirror_rect(Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER)));
|
||||
let (x0, x1) = if geo.rtl { (outer.right() - RULER, outer.right()) } else { (outer.left(), outer.left() + RULER) };
|
||||
(hr, Rect::from_min_max(pos2(x0, geo.cells.top()), pos2(x1, geo.cells.bottom())))
|
||||
}
|
||||
|
||||
/// Horizontal and vertical rulers (`outer` is the grid area including the rulers).
|
||||
pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) {
|
||||
let Some(pg) = geo.paper.clone() else { return };
|
||||
|
|
@ -523,17 +532,20 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo,
|
|||
let minor = if metric { 2.0 } else { 4.0 };
|
||||
let label_every = ((22.0 / unit).ceil() as usize).max(1);
|
||||
let font = theme::ui_font(9.5);
|
||||
let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER));
|
||||
let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom()));
|
||||
let (hr, vr) = ruler_rects(outer, geo);
|
||||
// Right-to-left sheets: the horizontal ruler is mirrored with the pages (ticks run from the
|
||||
// right margin leftwards).
|
||||
let mx = |x: f32| geo.mirror_x(x);
|
||||
p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window);
|
||||
p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window);
|
||||
p.rect_filled(Rect::from_min_max(pos2(vr.left(), outer.top()), pos2(vr.right(), outer.bottom())), 0.0, t.window);
|
||||
let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg);
|
||||
let [ml, mr, mt, mb, ..] = pg.margins;
|
||||
let [ml, mr, mt, mb, mh, mf] = pg.margins;
|
||||
// One ruler span per page: margins shaded, the printable part light, ticks from the margin.
|
||||
let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| {
|
||||
let band = |a: f32, b: f32| {
|
||||
if horizontal {
|
||||
Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0))
|
||||
let (a, b) = (mx(a), mx(b));
|
||||
Rect::from_min_max(pos2(a.min(b), area.top() + 2.0), pos2(a.max(b), area.bottom() - 2.0))
|
||||
} else {
|
||||
Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b))
|
||||
}
|
||||
|
|
@ -550,6 +562,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo,
|
|||
if pos > m1 + 0.5 {
|
||||
break;
|
||||
}
|
||||
let pos = if horizontal { mx(pos) } else { pos };
|
||||
let major = s.is_multiple_of(minor as usize);
|
||||
let len = if major {
|
||||
6.0
|
||||
|
|
@ -569,6 +582,8 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo,
|
|||
}
|
||||
let (a, b) = if horizontal {
|
||||
(pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0))
|
||||
} else if geo.rtl {
|
||||
(pos2(area.left() + 2.0, pos), pos2(area.left() + 2.0 + len, pos))
|
||||
} else {
|
||||
(pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos))
|
||||
};
|
||||
|
|
@ -586,14 +601,246 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo,
|
|||
let a = screen(geo, 0.0, y).y;
|
||||
let b = screen(geo, 0.0, y + h).y;
|
||||
span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr);
|
||||
// Header and footer margins: a small marker at each distance, grabbable like the margins.
|
||||
let pc = p.with_clip_rect(vr);
|
||||
for at in [screen(geo, 0.0, y + mh).y, screen(geo, 0.0, y + h - mf).y] {
|
||||
let (x, d) = if geo.rtl { (vr.right() - 2.0, -6.0) } else { (vr.left() + 2.0, 6.0) };
|
||||
pc.add(egui::Shape::convex_polygon(vec![pos2(x, at - 3.5), pos2(x, at + 3.5), pos2(x + d, at)], t.header_text, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line));
|
||||
p.line_segment(
|
||||
[pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())],
|
||||
if geo.rtl {
|
||||
[pos2(vr.left() + 0.5, outer.top()), pos2(vr.left() + 0.5, outer.bottom())]
|
||||
} else {
|
||||
[pos2(vr.right() - 0.5, outer.top()), pos2(vr.right() - 0.5, outer.bottom())]
|
||||
},
|
||||
Stroke::new(1.0, t.header_line),
|
||||
);
|
||||
}
|
||||
|
||||
/// A page edge that can be dragged on a ruler.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
/// Distance of the header from the top of the page.
|
||||
Header,
|
||||
/// Distance of the footer from the bottom of the page.
|
||||
Footer,
|
||||
}
|
||||
|
||||
impl Edge {
|
||||
/// Index into `PrintSettings::margins` / `PageGrid::margins`.
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Edge::Left => 0,
|
||||
Edge::Right => 1,
|
||||
Edge::Top => 2,
|
||||
Edge::Bottom => 3,
|
||||
Edge::Header => 4,
|
||||
Edge::Footer => 5,
|
||||
}
|
||||
}
|
||||
/// The `pageLayout.pageSetup` margin key.
|
||||
fn key(self) -> &'static str {
|
||||
["left", "right", "top", "bottom", "header", "footer"][self.index()]
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
["Left Margin", "Right Margin", "Top Margin", "Bottom Margin", "Header Margin", "Footer Margin"][self.index()]
|
||||
}
|
||||
fn horizontal(self) -> bool {
|
||||
matches!(self, Edge::Left | Edge::Right)
|
||||
}
|
||||
}
|
||||
|
||||
/// A margin being dragged on a ruler: the page it started on and the previewed value (inches).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RulerDrag {
|
||||
pub sheet: usize,
|
||||
pub edge: Edge,
|
||||
/// Page column (horizontal ruler) or row (vertical ruler) the drag started on.
|
||||
pub page: usize,
|
||||
pub start_inches: f32,
|
||||
pub inches: f32,
|
||||
/// The sheet re-paginated with `inches` (page breaks and fit-to-page scale as a commit would
|
||||
/// give them), rebuilt when the value changes.
|
||||
preview: Option<(f32, Arc<PageGrid>)>,
|
||||
}
|
||||
|
||||
/// Content that must stay between the margins, in points.
|
||||
const MIN_CONTENT_PT: f64 = 36.0;
|
||||
/// Screen distance within which a ruler margin edge is grabbed.
|
||||
const EDGE_GRAB: f32 = 4.0;
|
||||
|
||||
/// Margin (inches, rounded to 0.01) for an edge dragged to `rel_pt` points from the page's
|
||||
/// left/top edge. The margin stays on the page and leaves [`MIN_CONTENT_PT`] between the two
|
||||
/// margins of its axis. `None` for NaN/infinite input or a page without a size.
|
||||
pub fn margin_from_drag(edge: Edge, rel_pt: f64, page_pt: (f32, f32), margins_in: [f32; 6]) -> Option<f32> {
|
||||
let page = f64::from(if edge.horizontal() { page_pt.0 } else { page_pt.1 });
|
||||
if !rel_pt.is_finite() || !page.is_finite() || page <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let other = f64::from(margins_in.get(edge.index() ^ 1).copied().filter(|v| v.is_finite())?).max(0.0) * 72.0;
|
||||
let raw = match edge {
|
||||
Edge::Left | Edge::Top | Edge::Header => rel_pt,
|
||||
Edge::Right | Edge::Bottom | Edge::Footer => page - rel_pt,
|
||||
};
|
||||
let mut max = (page - other - MIN_CONTENT_PT).max(0.0);
|
||||
let mut min = 0.0_f64;
|
||||
let pt_of = |i: usize| f64::from(margins_in.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0)).max(0.0) * 72.0;
|
||||
match edge {
|
||||
// The header/footer sits within its page margin (conservative: never past the margin edge).
|
||||
Edge::Header => max = max.min(pt_of(2)),
|
||||
Edge::Footer => max = max.min(pt_of(3)),
|
||||
// ... so the top/bottom margin can't be dragged inside it.
|
||||
Edge::Top => min = pt_of(4),
|
||||
Edge::Bottom => min = pt_of(5),
|
||||
_ => {}
|
||||
}
|
||||
let pt = raw.clamp(min.min(max), max);
|
||||
Some(((pt / 72.0 * 100.0).round() / 100.0) as f32)
|
||||
}
|
||||
|
||||
/// The grid while a margin is dragged: the sheet is re-paginated with the previewed margin, so
|
||||
/// the content area, page breaks and the fit-to-page scale are those a commit would give.
|
||||
pub(crate) fn with_dragged_margin(app: &mut SheetApp, wb: &Workbook, si: usize, pg: Arc<PageGrid>) -> Arc<PageGrid> {
|
||||
let Some(d) = app.grid.ruler_drag.as_mut().filter(|d| d.sheet == si) else { return pg };
|
||||
if let Some((v, g)) = &d.preview
|
||||
&& (*v - d.inches).abs() < f32::EPSILON
|
||||
{
|
||||
return g.clone();
|
||||
}
|
||||
let mut w = wb.clone();
|
||||
let Some(sheet) = w.sheets.get_mut(si) else { return pg };
|
||||
let Some(slot) = Arc::make_mut(sheet).print.margins.get_mut(d.edge.index()) else { return pg };
|
||||
*slot = d.inches;
|
||||
let rows = pg.row_bands.last().map_or(0, |b| b.1);
|
||||
let cols = pg.col_bands.last().map_or(0, |b| b.1);
|
||||
let Some(g) = PageGrid::build(&w, si, rows, cols) else { return pg };
|
||||
let g = Arc::new(g);
|
||||
d.preview = Some((d.inches, g.clone()));
|
||||
g
|
||||
}
|
||||
|
||||
/// Points per layout unit of the grid (paper size over the layout page size).
|
||||
fn pt_per_unit(pg: &PageGrid) -> Option<f64> {
|
||||
let k = f64::from(pg.paper_pt.0) / pg.page_w;
|
||||
(k.is_finite() && k > 0.0).then_some(k)
|
||||
}
|
||||
|
||||
/// The margin edge of a visible page under `pos` on the horizontal (`h`) or vertical ruler.
|
||||
fn edge_at(geo: &Geo, pg: &PageGrid, pos: Pos2, h: bool) -> Option<(Edge, usize)> {
|
||||
let ((i0, i1), (j0, j1)) = visible_pages(geo, pg);
|
||||
let [ml, mr, mt, mb, mh, mf] = pg.margins;
|
||||
let mut best: Option<(f32, Edge, usize)> = None;
|
||||
let mut consider = |d: f32, e: Edge, page: usize| {
|
||||
if d <= EDGE_GRAB && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, e, page));
|
||||
}
|
||||
};
|
||||
if h {
|
||||
for j in j0..=j1.min(j0.saturating_add(200)) {
|
||||
let (x, _, w, _) = pg.page_rect(0, j);
|
||||
consider((screen(geo, x + ml, 0.0).x - pos.x).abs(), Edge::Left, j);
|
||||
consider((screen(geo, x + w - mr, 0.0).x - pos.x).abs(), Edge::Right, j);
|
||||
}
|
||||
} else {
|
||||
for i in i0..=i1.min(i0.saturating_add(200)) {
|
||||
let (_, y, _, hh) = pg.page_rect(i, 0);
|
||||
consider((screen(geo, 0.0, y + mt).y - pos.y).abs(), Edge::Top, i);
|
||||
consider((screen(geo, 0.0, y + hh - mb).y - pos.y).abs(), Edge::Bottom, i);
|
||||
consider((screen(geo, 0.0, y + mh).y - pos.y).abs(), Edge::Header, i);
|
||||
consider((screen(geo, 0.0, y + hh - mf).y - pos.y).abs(), Edge::Footer, i);
|
||||
}
|
||||
}
|
||||
best.map(|(_, e, p)| (e, p))
|
||||
}
|
||||
|
||||
/// Dragging the margin edges on the rulers sets the page margins: a resize cursor and a tooltip
|
||||
/// on hover, a live preview while dragging, one `pageLayout.pageSetup` (one undo step) on
|
||||
/// release, Esc cancels.
|
||||
fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid, sh: &Sheet, si: usize) {
|
||||
let Some(outer) = app.grid.rect else { return };
|
||||
if geo.rect.top() - outer.top() < RULER - 0.5 {
|
||||
app.grid.ruler_drag = None;
|
||||
return;
|
||||
}
|
||||
// Leaving the sheet or the view drops the drag.
|
||||
if app.grid.ruler_drag.as_ref().is_some_and(|d| d.sheet != si) {
|
||||
app.grid.ruler_drag = None;
|
||||
}
|
||||
let (hr, vr) = ruler_rects(outer, geo);
|
||||
// Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical.
|
||||
let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag());
|
||||
let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag());
|
||||
let margins_in = |sh: &Sheet| -> [f32; 6] {
|
||||
let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5);
|
||||
[m(0), m(1), m(2), m(3), m(4), m(5)]
|
||||
};
|
||||
let current = margins_in(sh);
|
||||
let metric = metric(app);
|
||||
let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| {
|
||||
let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") };
|
||||
let text = format!("{}: {value}", app.ui.language.tr(edge.label()));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(text);
|
||||
});
|
||||
};
|
||||
let cursor = |ui: &egui::Ui, edge: Edge| {
|
||||
ui.ctx().set_cursor_icon(if edge.horizontal() { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
|
||||
};
|
||||
if let Some(d) = app.grid.ruler_drag.clone() {
|
||||
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
|
||||
app.grid.ruler_drag = None;
|
||||
return;
|
||||
}
|
||||
cursor(ui, d.edge);
|
||||
let down = ui.input(|i| i.pointer.primary_down());
|
||||
let pos = ui.input(|i| i.pointer.latest_pos()).map(|p| geo.mirror_pos(p));
|
||||
let mut cur = d.clone();
|
||||
if let (Some(pos), Some(k)) = (pos, pt_per_unit(pg)) {
|
||||
let rel = if d.edge.horizontal() {
|
||||
let page_x = pg.page_x(d.page);
|
||||
f64::from(geo.scroll.x) + f64::from((pos.x - geo.cells.left()) / geo.z) - page_x
|
||||
} else {
|
||||
let page_y = pg.page_y(d.page);
|
||||
f64::from(geo.scroll.y) + f64::from((pos.y - geo.cells.top()) / geo.z) - page_y
|
||||
};
|
||||
if let Some(v) = margin_from_drag(d.edge, rel * k, pg.paper_pt, current) {
|
||||
cur.inches = v;
|
||||
}
|
||||
}
|
||||
if down {
|
||||
tip(ui, app, cur.edge, cur.inches);
|
||||
app.grid.ruler_drag = Some(cur);
|
||||
ui.ctx().request_repaint();
|
||||
} else {
|
||||
app.grid.ruler_drag = None;
|
||||
if (cur.inches - cur.start_inches).abs() > f32::EPSILON {
|
||||
app.run_or_alert("pageLayout.pageSetup", json!({"sheet": si, "margins": {cur.edge.key(): cur.inches}}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (resp, h) in [(&hresp, true), (&vresp, false)] {
|
||||
let Some(pos) = resp.hover_pos().or_else(|| resp.interact_pointer_pos()) else { continue };
|
||||
let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(pos) } else { pos };
|
||||
let origin = geo.mirror_pos(origin);
|
||||
let Some((edge, page)) = edge_at(geo, pg, origin, h) else { continue };
|
||||
let value = current.get(edge.index()).copied().unwrap_or(0.5);
|
||||
if resp.drag_started() {
|
||||
app.grid.ruler_drag = Some(RulerDrag { sheet: si, edge, page, start_inches: value, inches: value, preview: None });
|
||||
cursor(ui, edge);
|
||||
} else if resp.hovered() {
|
||||
cursor(ui, edge);
|
||||
tip(ui, app, edge, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Header & Footer contextual ribbon tab.
|
||||
pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
use crate::widgets::big_button;
|
||||
|
|
@ -667,3 +914,47 @@ fn sep(ui: &mut egui::Ui) {
|
|||
let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover());
|
||||
ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A4: (f32, f32) = (595.0, 842.0);
|
||||
|
||||
#[test]
|
||||
fn drag_maps_to_margins_and_clamps_inside_the_page() {
|
||||
let m = [0.7, 0.7, 0.75, 0.75, 0.3, 0.3];
|
||||
// 72 pt from the left edge is one inch; from the right edge the margin is measured inward.
|
||||
assert_eq!(margin_from_drag(Edge::Left, 72.0, A4, m), Some(1.0));
|
||||
assert_eq!(margin_from_drag(Edge::Right, f64::from(A4.0) - 36.0, A4, m), Some(0.5));
|
||||
assert_eq!(margin_from_drag(Edge::Top, 108.0, A4, m), Some(1.5));
|
||||
// Dragged off the page: never negative (top/bottom keep the header/footer distance).
|
||||
assert_eq!(margin_from_drag(Edge::Left, -50.0, A4, m), Some(0.0));
|
||||
assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) + 99.0, A4, m), Some(0.3));
|
||||
// Dragged across the opposite margin: stops a minimum content width short of it.
|
||||
let max = margin_from_drag(Edge::Left, 10_000.0, A4, m).unwrap();
|
||||
assert!((f64::from(max) * 72.0 - (f64::from(A4.0) - 0.7 * 72.0 - MIN_CONTENT_PT)).abs() < 1.0, "{max}");
|
||||
// Hostile input is ignored, not clamped to something arbitrary.
|
||||
assert_eq!(margin_from_drag(Edge::Left, f64::NAN, A4, m), None);
|
||||
assert_eq!(margin_from_drag(Edge::Left, f64::INFINITY, A4, m), None);
|
||||
assert_eq!(margin_from_drag(Edge::Left, 10.0, (0.0, 0.0), m), None);
|
||||
assert_eq!(margin_from_drag(Edge::Left, 10.0, A4, [0.7, f32::NAN, 0.7, 0.7, 0.3, 0.3]), None);
|
||||
// Oversized opposite margin leaves nothing to drag into.
|
||||
assert_eq!(margin_from_drag(Edge::Left, 300.0, A4, [0.0, 40.0, 0.0, 0.0, 0.3, 0.3]), Some(0.0));
|
||||
// Header/footer distances are measured from the top / bottom edge.
|
||||
assert_eq!(margin_from_drag(Edge::Header, 36.0, A4, m), Some(0.5));
|
||||
assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 18.0, A4, m), Some(0.25));
|
||||
assert_eq!(margin_from_drag(Edge::Footer, 1e9, A4, m), Some(0.0));
|
||||
// Header stays within the top margin (0.75 in), footer within the bottom margin.
|
||||
assert_eq!(margin_from_drag(Edge::Header, 500.0, A4, m), Some(0.75));
|
||||
assert_eq!(margin_from_drag(Edge::Footer, f64::from(A4.1) - 500.0, A4, m), Some(0.75));
|
||||
// The top/bottom margin can't be dragged inside the header/footer distance (0.3 in).
|
||||
assert_eq!(margin_from_drag(Edge::Top, 2.0, A4, m), Some(0.3));
|
||||
assert_eq!(margin_from_drag(Edge::Bottom, f64::from(A4.1) - 2.0, A4, m), Some(0.3));
|
||||
// Hostile header/footer values (NaN, negative, huge) never panic or escape the page.
|
||||
assert_eq!(margin_from_drag(Edge::Header, 10.0, A4, [0.7, 0.7, 0.75, 0.75, f32::NAN, 0.3]), Some(0.14));
|
||||
assert_eq!(margin_from_drag(Edge::Top, 300.0, A4, [0.7, 0.7, 0.75, 0.75, -5.0, f32::INFINITY]), Some(4.17));
|
||||
assert_eq!(margin_from_drag(Edge::Header, f64::NAN, A4, m), None);
|
||||
assert!(margin_from_drag(Edge::Footer, 10.0, A4, [0.0, 0.0, 1e9, 1e9, 0.3, 1e9]).is_some_and(|v| (0.0..=11.7).contains(&v)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2117,6 +2117,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let lang = app.ui.language;
|
||||
let page_layout =
|
||||
app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false);
|
||||
// Two columns of two, as in Excel's Show group: four stacked rows overflow the ribbon body.
|
||||
ui.vertical(|ui| {
|
||||
// Rulers exist only in Page Layout view.
|
||||
let mut ruler = !app.session.hide_ruler;
|
||||
|
|
@ -2125,18 +2126,21 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if ruler_resp.changed() {
|
||||
act(app, "view.ruler", json!({"on": ruler}));
|
||||
}
|
||||
let mut fb = app.session.prefs.formula_bar;
|
||||
let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar"));
|
||||
control_keytip(app, ui, &resp, "view.formulaBar");
|
||||
if resp.changed() || invoke(app, "view.formulaBar") {
|
||||
act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)}));
|
||||
}
|
||||
let mut g = s.0;
|
||||
let resp = ui.checkbox(&mut g, lang.tr("Gridlines"));
|
||||
control_keytip(app, ui, &resp, "view.gridlines");
|
||||
if resp.changed() {
|
||||
act(app, "view.gridlines", json!({"on": g}));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.vertical(|ui| {
|
||||
let mut fb = app.session.prefs.formula_bar;
|
||||
let resp = ui.checkbox(&mut fb, lang.tr("Formula Bar"));
|
||||
control_keytip(app, ui, &resp, "view.formulaBar");
|
||||
if resp.changed() || invoke(app, "view.formulaBar") {
|
||||
act(app, "view.formulaBar", json!({"on": checkbox_on(resp.changed(), fb)}));
|
||||
}
|
||||
let mut h = s.1;
|
||||
let resp = ui.checkbox(&mut h, lang.tr("Headings"));
|
||||
control_keytip(app, ui, &resp, "view.headings");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use gridcraft_engine::model::{Sheet, Visibility};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -68,9 +68,18 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||
.filter(|(_, s)| s.visibility == Visibility::Visible)
|
||||
.map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet))
|
||||
.collect();
|
||||
// Keep worksheet tabs in their own horizontally scrollable
|
||||
// viewport, leaving room for the New Sheet button and grid scrollbar.
|
||||
let tabs_width = (ui.available_width() - 180.0).max(100.0);
|
||||
// Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the
|
||||
// tabs get 60% of the bar and the grid's horizontal scrollbar the rest.
|
||||
let tabs_width = (ui.available_width() * 0.6).max(100.0);
|
||||
// Reveal the selected sheet after navigation or layout changes, but
|
||||
// allow browsing other tabs without snapping back on every frame.
|
||||
let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits()));
|
||||
let reveal_id = ui.id().with("worksheet_tabs_reveal");
|
||||
let reveal_active = ui.ctx().data_mut(|data| {
|
||||
let changed = data.get_temp::<egui::Id>(reveal_id) != Some(reveal_key);
|
||||
data.insert_temp(reveal_id, reveal_key);
|
||||
changed
|
||||
});
|
||||
ui.allocate_ui_with_layout(vec2(tabs_width, 30.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
// Translate vertical wheel movement into horizontal
|
||||
// worksheet-tab scrolling.
|
||||
|
|
@ -99,6 +108,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let tw = ui.painter().layout_no_wrap(name.clone(), font.clone(), t.text).size().x;
|
||||
let w = (tw + 32.0).max(72.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click_and_drag());
|
||||
if active && reveal_active {
|
||||
resp.scroll_to_me(None);
|
||||
}
|
||||
let r = r.shrink2(vec2(0.0, 1.0));
|
||||
if active {
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.left(), r.top() - 2.0), r.max), 4.0, t.tab_active);
|
||||
|
|
@ -203,38 +215,11 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if crate::widgets::icon_button(ui, Icon::Plus, t.text_dim, "New sheet (⇧F11)", vec2(26.0, 26.0)).clicked() {
|
||||
app.run_or_alert("home.insertSheet", json!({}));
|
||||
}
|
||||
// Horizontal position indicator (a slim scrollbar).
|
||||
// The track ends where the grid does, so it stops under the vertical scrollbar.
|
||||
let avail = ui.available_rect_before_wrap();
|
||||
if avail.width() > 120.0 {
|
||||
let bar = Rect::from_min_max(pos2(avail.left() + 40.0, avail.center().y - 4.0), pos2(avail.right() - 16.0, avail.center().y + 4.0));
|
||||
let used_w = app
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.active().and_then(|s| s.used_range().map(|u| s.col_left(u.end.col + 10) as f32)))
|
||||
.unwrap_or(2000.0)
|
||||
.max(2000.0);
|
||||
let view = app.view();
|
||||
let vis = app.grid.cells_rect.map(|r| r.width()).unwrap_or(1000.0);
|
||||
let total = used_w.max(view.scroll.x + vis);
|
||||
let frac0 = (view.scroll.x / total).clamp(0.0, 1.0);
|
||||
let frac1 = ((view.scroll.x + vis) / total).clamp(0.0, 1.0);
|
||||
let thumb = Rect::from_min_max(
|
||||
pos2(bar.left() + bar.width() * frac0, bar.top()),
|
||||
pos2(bar.left() + bar.width() * frac1.max(frac0 + 0.03), bar.bottom()),
|
||||
);
|
||||
let resp = ui.interact(bar, ui.id().with("hscroll"), Sense::click_and_drag());
|
||||
ui.painter().rect_filled(
|
||||
thumb,
|
||||
4.0,
|
||||
if resp.hovered() || resp.dragged() { Color32::from_gray(150) } else { Color32::from_gray(190) },
|
||||
);
|
||||
if resp.dragged() {
|
||||
let dx = resp.drag_delta().x / bar.width() * total;
|
||||
if let Some(v) = app.view_mut() {
|
||||
v.scroll.x = (v.scroll.x + dx).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let right = app.grid.rect.map_or(avail.right(), |r| r.right()).min(avail.right());
|
||||
let track = Rect::from_min_max(pos2(avail.left() + 8.0, avail.center().y - 7.0), pos2(right, avail.center().y + 7.0));
|
||||
crate::grid::horizontal_scrollbar(app, ui, track, &t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,27 @@ fn copying_multiple_areas_copies_lined_up_areas_and_explains_the_rest() {
|
|||
assert_eq!(h.state().session.clipboard.as_ref().map(|c| c.areas.len()), Some(2), "the earlier copy stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_pastes_the_copied_cells_and_ends_copy_mode() {
|
||||
// #845: with the marching ants showing, Enter pastes into the selection and cancels the copy.
|
||||
let mut h = harness(Services::default());
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap();
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
let value = |r, c| h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(r, c))));
|
||||
assert_eq!(value(4, 3), Some(gridcraft_engine::core::Value::text("Name")));
|
||||
assert_eq!(value(5, 4), Some(gridcraft_engine::core::Value::number(42.0)));
|
||||
assert!(h.state().session.clipboard.is_none(), "the marching ants are gone");
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D5".to_string()), "Enter does not also move down");
|
||||
|
||||
// Copy mode is over: the next Enter just moves.
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active.a1()), Some("D6".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_the_ribbon_paste_button_or_its_menu_does_not_freeze() {
|
||||
// #273: a click on Paste deadlocked the UI thread (a click-state query inside the egui context
|
||||
|
|
|
|||
|
|
@ -58,6 +58,72 @@ fn vertical_scrollbar_pages_and_drags_without_changing_selection() {
|
|||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
|
||||
}
|
||||
|
||||
/// The first column painted right of the frozen ones.
|
||||
fn first_scrolled_col(h: &egui_kittest::Harness<'static, SheetApp>) -> u32 {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = app.grid.geo.as_ref().unwrap();
|
||||
geo.col_at(sheet, geo.cells.left() + geo.frozen_w + 0.5)
|
||||
}
|
||||
|
||||
fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, p: egui::Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_scrollbar_pages_and_drags_across_a_wide_sheet() {
|
||||
let mut wb = gridcraft_engine::model::Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for c in 0..26 {
|
||||
sh.set_value(CellRef::new(0, c), Value::text(format!("col {}", c + 1)));
|
||||
}
|
||||
// Column A frozen: a page is the scrolling part only.
|
||||
sh.freeze = Some((0, 1));
|
||||
let mut s = Session::new();
|
||||
s.add_document(gridcraft_engine::DocState::new(wb, None, "Book1".into()));
|
||||
let mut h = harness(s);
|
||||
let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar");
|
||||
assert!(track.width() >= 200.0, "the scrollbar gets a usable share of the tab bar: {track:?}");
|
||||
assert_eq!(first_scrolled_col(&h), 1);
|
||||
|
||||
let page = {
|
||||
let geo = h.state().grid.geo.as_ref().unwrap();
|
||||
(geo.cells.width() - geo.frozen_w) / geo.z
|
||||
};
|
||||
click(&mut h, egui::pos2(track.right() - 6.0, track.center().y));
|
||||
let scrolled = h.state().view().scroll.x;
|
||||
assert!((scrolled - page).abs() < 0.5, "clicking the track right of the thumb scrolls one page: {scrolled} vs {page}");
|
||||
assert!(first_scrolled_col(&h) > 1);
|
||||
let geo = h.state().grid.geo.clone().unwrap();
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(geo.x(&sheet, 0), geo.cells.left(), "the frozen column stays put");
|
||||
click(&mut h, egui::pos2(track.right() - 6.0, track.center().y));
|
||||
let further = h.state().view().scroll.x;
|
||||
click(&mut h, egui::pos2(track.left() + 2.0, track.center().y));
|
||||
assert!(h.state().view().scroll.x < further, "clicking left of the thumb scrolls back");
|
||||
|
||||
h.state_mut().view_mut().unwrap().scroll.x = 0.0;
|
||||
h.run_steps(2);
|
||||
let start = egui::pos2(track.left() + 6.0, track.center().y);
|
||||
h.input_mut().events.push(Event::PointerMoved(start));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: start, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
let end = start + egui::vec2(120.0, 0.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(end));
|
||||
h.run_steps(2);
|
||||
let dragged = h.state().view().scroll.x;
|
||||
assert!(first_scrolled_col(&h) > 1, "dragging the thumb right changes the first visible column");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().view().scroll.x, dragged, "holding the thumb still must not keep scrolling");
|
||||
h.input_mut().events.push(Event::PointerButton { pos: end, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active, CellRef::new(0, 0));
|
||||
}
|
||||
|
||||
fn key(h: &mut egui_kittest::Harness<'static, SheetApp>, k: Key, m: Modifiers) {
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
h.input_mut().events.push(Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
|
|
@ -337,6 +403,25 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
|||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_draws_its_labels_in_the_interface_language() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
|
||||
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated");
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
h.get_by_label("Wissenschaftlich").click();
|
||||
h.run_steps(2);
|
||||
let style = &h.state().dialog.as_ref().unwrap().values["style"];
|
||||
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
|
||||
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Scientific").is_some(), "English is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_manager_new_and_edit_open_the_name_dialog_and_come_back() {
|
||||
// #238: New… in the Name Manager did nothing (the manager put itself back over the dialog it
|
||||
|
|
|
|||
|
|
@ -149,3 +149,68 @@ fn space_shortcuts_per_platform() {
|
|||
// Cmd+Space is Spotlight on macOS.
|
||||
assert_eq!(space(mac_cmd, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_down_opens_autofilter_headers_without_changing_the_workbook() {
|
||||
for rtl in [false, true] {
|
||||
for cell in ["A1", "B1"] {
|
||||
let mut session = book(cell);
|
||||
session.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
|
||||
session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
|
||||
let mut h = harness(session);
|
||||
let before = h.state().session.active().unwrap().wb.clone();
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
let (column, _row, at) = h.state().grid.filter_menu.expect("Alt+Down must open the filter header menu");
|
||||
let d = h.state().session.active().unwrap();
|
||||
let active = CellRef::parse(cell).unwrap();
|
||||
assert_eq!(column, active.col);
|
||||
assert_eq!(d.selection.active, active);
|
||||
assert_eq!(d.wb, before);
|
||||
let sh = d.wb.active().unwrap();
|
||||
let geo = gridcraft_ui_egui::grid::Geo::new(sh, h.state().grid.rect.unwrap(), h.state().view().scroll);
|
||||
assert_eq!(at, geo.mirror_pos(gridcraft_ui_egui::grid::filter_button_rect(&geo, sh, active).center()));
|
||||
// Opening focuses the existing search box; typing must not edit the header.
|
||||
h.input_mut().events.push(Event::Text("1".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.ctx.data(|data| data.get_temp::<String>(egui::Id::new(("filter_search", column)))).as_deref(), Some("1"));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(h.state().session.active().unwrap().wb, before);
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
assert!(h.state().grid.filter_menu.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_down_keeps_validation_priority_and_plain_down_navigation() {
|
||||
let mut session = book("A1");
|
||||
session.execute("data.filter", json!({"range": "A1:B3"})).unwrap();
|
||||
session.execute("data.validation", json!({"range": "A1", "type": "list", "formula1": "\"First,Second\""})).unwrap();
|
||||
let mut h = harness(session);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
assert_eq!(h.state().grid.list_picker, Some(CellRef::parse("A1").unwrap()));
|
||||
assert!(h.state().grid.filter_menu.is_none());
|
||||
key(&mut h, Key::Escape, Modifiers::NONE);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2");
|
||||
assert!(h.state().grid.filter_menu.is_none());
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
assert!(h.state().grid.filter_menu.is_none(), "a data cell has no filter button");
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "A2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_down_only_opens_enabled_table_filter_buttons() {
|
||||
for enabled in [false, true] {
|
||||
let mut session = book("B1");
|
||||
session.execute("insert.table", json!({"range": "A1:B3"})).unwrap();
|
||||
if !enabled {
|
||||
session.execute("table.headerRow", json!({"on": false})).unwrap();
|
||||
}
|
||||
session.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
let mut h = harness(session);
|
||||
key(&mut h, Key::ArrowDown, Modifiers::ALT);
|
||||
assert_eq!(h.state().grid.filter_menu.map(|(column, _, _)| column), enabled.then_some(1));
|
||||
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,20 @@ use serde_json::json;
|
|||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn fixture() -> Harness {
|
||||
fixture_with(false, None)
|
||||
}
|
||||
|
||||
/// `rtl` mirrors the sheet; `setup` is extra `pageLayout.pageSetup` params.
|
||||
fn fixture_with(rtl: bool, setup: Option<serde_json::Value>) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
if rtl {
|
||||
session.execute("view.rightToLeft", json!({"on": true})).unwrap();
|
||||
}
|
||||
if let Some(p) = setup {
|
||||
session.execute("cell.set", json!({"cell": "Z1", "input": "wide"})).unwrap();
|
||||
session.execute("pageLayout.pageSetup", p).unwrap();
|
||||
}
|
||||
for r in 1..=60 {
|
||||
session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap();
|
||||
}
|
||||
|
|
@ -162,3 +174,201 @@ fn insert_header_and_footer_switches_to_page_layout_view() {
|
|||
let e = h.state().grid.hf_edit.as_ref().expect("the header is being edited");
|
||||
assert_eq!((e.footer, e.section, e.texts[0].as_str()), (false, 1, "Left"));
|
||||
}
|
||||
|
||||
fn margins(h: &Harness) -> [f32; 6] {
|
||||
h.state().session.doc().unwrap().wb.active().unwrap().print.margins
|
||||
}
|
||||
|
||||
fn mouse(h: &mut Harness, ev: Event) {
|
||||
h.input_mut().events.push(ev);
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_ruler_edge_previews_commits_on_release_as_one_undo_step_and_esc_cancels() {
|
||||
let mut h = fixture();
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start_margins = margins(&h);
|
||||
let ruler_y = h.state().grid.rect.unwrap().top() + 9.0;
|
||||
let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y);
|
||||
// One inch further in = 96 layout pixels at 100% print scale; use the screen scale to be exact.
|
||||
let per_inch = (72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z;
|
||||
let target = pos2(edge.x + per_inch, ruler_y);
|
||||
h.run_steps(40);
|
||||
mouse(&mut h, Event::PointerMoved(edge));
|
||||
mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
mouse(&mut h, Event::PointerMoved(pos2(edge.x + 5.0, ruler_y)));
|
||||
mouse(&mut h, Event::PointerMoved(target));
|
||||
h.run_steps(2);
|
||||
// Live preview: the page content moved, the document did not change yet.
|
||||
let live = geo(&h).paper.unwrap().margins[0];
|
||||
assert!(live > pg.margins[0] + 1.0, "{live} vs {}", pg.margins[0]);
|
||||
assert_eq!(margins(&h), start_margins);
|
||||
let want = (start_margins[0] + 1.0 * 1.0).min(10.0);
|
||||
mouse(&mut h, Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let after = margins(&h);
|
||||
assert!((after[0] - want).abs() < 0.03, "{after:?} want left {want}");
|
||||
assert_eq!(after[1..], start_margins[1..]);
|
||||
// One undo step restores it.
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(margins(&h), start_margins);
|
||||
// Esc during a drag cancels: nothing is committed.
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let edge = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, ruler_y);
|
||||
h.run_steps(40);
|
||||
mouse(&mut h, Event::PointerMoved(edge));
|
||||
mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
mouse(&mut h, Event::PointerMoved(pos2(edge.x + per_inch, ruler_y)));
|
||||
mouse(&mut h, Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
mouse(&mut h, Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
assert_eq!(margins(&h), start_margins);
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, p: Pos2) {
|
||||
h.run_steps(40);
|
||||
mouse(h, Event::PointerMoved(p));
|
||||
mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
}
|
||||
|
||||
fn release(h: &mut Harness, p: Pos2) {
|
||||
mouse(h, Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Screen pixels per inch of the page.
|
||||
fn per_inch(g: &Geo) -> f32 {
|
||||
let pg = g.paper.clone().unwrap();
|
||||
(72.0 / (pg.paper_pt.0 as f64 / pg.page_w)) as f32 * g.z
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_and_footer_margins_are_dragged_on_the_vertical_ruler() {
|
||||
let mut h = fixture();
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start = margins(&h);
|
||||
let ruler_x = h.state().grid.rect.unwrap().left() + 9.0;
|
||||
// Header: distance from the top of the page, 0.2 in further down.
|
||||
let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y);
|
||||
let to = pos2(ruler_x, at.y + 0.2 * per_inch(&g));
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
release(&mut h, to);
|
||||
let after = margins(&h);
|
||||
assert!((after[4] - (start[4] + 0.2)).abs() < 0.03, "{after:?} vs {start:?}");
|
||||
assert_eq!((after[..4].to_vec(), after[5]), (start[..4].to_vec(), start[5]));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(margins(&h), start);
|
||||
// Footer: distance from the bottom, dragged up by 0.2 in grows it (zoom out so the page
|
||||
// bottom is on screen).
|
||||
h.state_mut().run("view.zoom", json!({"percent": 50})).unwrap();
|
||||
h.run_steps(4);
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let (_, py, _, ph) = pg.page_rect(0, 0);
|
||||
let at = pos2(ruler_x, screen(&g, 0.0, py + ph - pg.margins[5]).y);
|
||||
let to = pos2(ruler_x, at.y - 0.2 * per_inch(&g));
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y - 4.0)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
release(&mut h, to);
|
||||
assert!((margins(&h)[5] - (start[5] + 0.2)).abs() < 0.03, "{:?}", margins(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_sheets_mirror_the_horizontal_ruler_drag() {
|
||||
let mut h = fixture_with(true, None);
|
||||
let g = geo(&h);
|
||||
assert!(g.rtl);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start = margins(&h);
|
||||
let y = h.state().grid.rect.unwrap().top() + 9.0;
|
||||
// The left margin edge is drawn on the right of the mirrored page; dragging it towards the
|
||||
// page centre (screen-left) grows the left margin.
|
||||
let at = pos2(g.mirror_x(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x), y);
|
||||
let to = pos2(at.x - per_inch(&g), y);
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(at.x - 4.0, y)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
release(&mut h, to);
|
||||
let after = margins(&h);
|
||||
assert!((after[0] - (start[0] + 1.0)).abs() < 0.03, "{after:?} vs {start:?}");
|
||||
assert_eq!(after[1], start[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_repaginates_so_fit_to_width_matches_the_commit() {
|
||||
let mut h = fixture_with(false, Some(json!({"fitWidth": 1, "fitHeight": 0})));
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start = margins(&h);
|
||||
let y = h.state().grid.rect.unwrap().top() + 9.0;
|
||||
let at = pos2(screen(&g, pg.page_x(0) + pg.margins[0], 0.0).x, y);
|
||||
let to = pos2(at.x + 2.0 * per_inch(&g), y);
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(at.x + 4.0, y)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
h.run_steps(2);
|
||||
let live_scale = geo(&h).paper.unwrap().scale;
|
||||
assert_eq!(margins(&h), start);
|
||||
assert!(live_scale < pg.scale, "live scale {live_scale} should shrink below {}", pg.scale);
|
||||
release(&mut h, to);
|
||||
h.run_steps(2);
|
||||
let committed = geo(&h).paper.unwrap().scale;
|
||||
assert!((committed - live_scale).abs() < 0.01, "committed {committed} vs preview {live_scale}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_sheets_carry_the_vertical_ruler_on_the_right() {
|
||||
let mut h = fixture_with(true, None);
|
||||
let g = geo(&h);
|
||||
assert!(g.rtl);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start = margins(&h);
|
||||
let outer = h.state().grid.rect.unwrap();
|
||||
// The grid leaves the ruler's width free on the right, not on the left.
|
||||
assert!((g.rect.right() - (outer.right() - gridcraft_ui_egui::paper_view::RULER)).abs() < 0.5, "{:?} {outer:?}", g.rect);
|
||||
assert!((g.rect.left() - outer.left()).abs() < 0.5);
|
||||
let x = outer.right() - 9.0;
|
||||
let at = pos2(x, screen(&g, 0.0, pg.page_y(0) + pg.margins[2]).y);
|
||||
let to = pos2(x, at.y + 0.25 * per_inch(&g));
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(x, at.y + 4.0)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
release(&mut h, to);
|
||||
let after = margins(&h);
|
||||
assert!((after[2] - (start[2] + 0.25)).abs() < 0.03, "{after:?} vs {start:?}");
|
||||
// The left ruler strip is not a handle any more: nothing happens there.
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let at = pos2(outer.left() + 9.0, screen(&g, 0.0, pg.page_y(0) + pg.margins[3]).y);
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(at.x, at.y - 30.0)));
|
||||
release(&mut h, pos2(at.x, at.y - 30.0));
|
||||
assert_eq!(margins(&h)[3], start[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_distance_cannot_be_dragged_past_the_top_margin() {
|
||||
let mut h = fixture();
|
||||
let g = geo(&h);
|
||||
let pg = g.paper.clone().unwrap();
|
||||
let start = margins(&h);
|
||||
let ruler_x = h.state().grid.rect.unwrap().left() + 9.0;
|
||||
let at = pos2(ruler_x, screen(&g, 0.0, pg.page_y(0) + pg.margins[4]).y);
|
||||
let to = pos2(ruler_x, at.y + 3.0 * per_inch(&g));
|
||||
press(&mut h, at);
|
||||
mouse(&mut h, Event::PointerMoved(pos2(ruler_x, at.y + 4.0)));
|
||||
mouse(&mut h, Event::PointerMoved(to));
|
||||
release(&mut h, to);
|
||||
let after = margins(&h);
|
||||
assert!((after[4] - start[2]).abs() < 0.011, "header {} should stop at the top margin {}", after[4], start[2]);
|
||||
assert_eq!(after[2], start[2]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
//! Overflowing worksheet tabs, exercised through their actual painted geometry and input.
|
||||
use egui::{Event, Modifiers, PointerButton, Rect, Shape, pos2, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness(width: f32, active: usize) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("sheet.rename", json!({"sheet":0,"name":"Tab00"})).unwrap();
|
||||
for i in 1..30 {
|
||||
s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap();
|
||||
}
|
||||
s.execute("sheet.activate", json!({"sheet":active})).unwrap();
|
||||
let mut fonts_ready = false;
|
||||
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state(
|
||||
move |ui, app: &mut SheetApp| {
|
||||
if !fonts_ready {
|
||||
ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions());
|
||||
fonts_ready = true;
|
||||
return;
|
||||
}
|
||||
app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0)));
|
||||
gridcraft_ui_egui::tabs::sheet_tabs(app, ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(20);
|
||||
h
|
||||
}
|
||||
|
||||
fn tab(h: &Harness, name: &str) -> Option<(Rect, Rect)> {
|
||||
h.output().shapes.iter().find_map(|s| match &s.shape {
|
||||
Shape::Text(t) if t.galley.job.text == name => Some((t.visual_bounding_rect(), s.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_visible(h: &Harness, name: &str) {
|
||||
let (text, clip) = tab(h, name).unwrap_or_else(|| panic!("{name} was not painted in the tab viewport"));
|
||||
assert!(clip.contains_rect(text), "{name} is clipped: text={text:?}, viewport={clip:?}");
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness, at: egui::Pos2) {
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_and_activating_late_sheets_reveals_their_tabs() {
|
||||
for width in [500.0, 1000.0] {
|
||||
let mut h = harness(width, 29);
|
||||
assert_visible(&h, "Tab29");
|
||||
h.state_mut().run("sheet.activate", json!({"sheet":0})).unwrap();
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, "Tab00");
|
||||
h.state_mut().run("sheet.activate", json!({"sheet":20})).unwrap();
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, "Tab20");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_arrows_reveal_the_selected_tab() {
|
||||
let mut h = harness(700.0, 0);
|
||||
let (_, clip) = tab(&h, "Tab00").unwrap();
|
||||
// The Next arrow sits left of the All Sheets button (24 px) and the 8 px gap before the tabs.
|
||||
let next = pos2(clip.left() - 44.0, clip.center().y);
|
||||
for _ in 0..29 {
|
||||
click(&mut h, next);
|
||||
}
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29);
|
||||
assert_visible(&h, "Tab29");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() {
|
||||
for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] {
|
||||
let mut h = harness(700.0, 0);
|
||||
let before = h.state().session.doc().unwrap().wb.clone();
|
||||
let (_, clip) = tab(&h, "Tab00").unwrap();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(clip.center()),
|
||||
Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
phase: egui::TouchPhase::Start,
|
||||
delta: vec2(0.0, 0.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
},
|
||||
]);
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
phase: egui::TouchPhase::Move,
|
||||
delta,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
phase: egui::TouchPhase::End,
|
||||
delta: vec2(0.0, 0.0),
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(40);
|
||||
assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "gesture should browse away from the selected tab: {delta:?}");
|
||||
assert_eq!(h.state().session.doc().unwrap().wb, before, "browsing tabs must not edit the workbook");
|
||||
h.run_steps(40);
|
||||
assert!(!tab(&h, "Tab00").is_some_and(|(r, c)| c.contains_rect(r)), "idle frames must not snap back");
|
||||
h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap();
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, "Tab29");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() {
|
||||
let mut h = harness(1000.0, 0);
|
||||
for sheet in 1..10 {
|
||||
h.state_mut().run("sheet.hide", json!({"sheet":sheet})).unwrap();
|
||||
}
|
||||
let name = "Quarterly results and forecasts";
|
||||
h.state_mut().run("sheet.rename", json!({"sheet":29,"name":name})).unwrap();
|
||||
h.state_mut().run("sheet.activate", json!({"sheet":29})).unwrap();
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, name);
|
||||
// Narrow, but still wide enough for the long tab next to the arrows and the All Sheets button.
|
||||
h.set_size(vec2(560.0, 120.0));
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, name);
|
||||
h.set_size(vec2(1000.0, 120.0));
|
||||
h.run_steps(20);
|
||||
assert_visible(&h, name);
|
||||
assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_sheet_button_stays_reachable_and_reveals_the_inserted_tab() {
|
||||
let mut h = harness(700.0, 29);
|
||||
let (_, clip) = tab(&h, "Tab29").unwrap();
|
||||
click(&mut h, pos2(clip.right() + 18.0, clip.center().y));
|
||||
let d = h.state().session.doc().unwrap();
|
||||
assert_eq!(d.wb.sheets.len(), 31);
|
||||
let name = d.wb.active().unwrap().name.clone();
|
||||
assert_visible(&h, &name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_leaves_the_worksheet_scrollbar_reachable() {
|
||||
// The tab bar's horizontal scrollbar track keeps its share of the bar however many tabs
|
||||
// overflow; dragging its thumb is covered with a rendered grid in `interaction.rs`.
|
||||
for width in [500.0, 700.0, 1200.0] {
|
||||
let h = harness(width, 0);
|
||||
let track = h.state().grid.hscroll.expect("the tab bar has a horizontal scrollbar");
|
||||
assert!(track.width() >= 100.0, "{width}: the scrollbar keeps a usable width: {track:?}");
|
||||
assert!(track.left() >= 0.0 && track.right() <= width + 0.5, "{width}: the scrollbar is offscreen: {track:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
//! Ctrl+wheel zoom steps along a ladder of percentages that always comes back to 100%.
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, vec2};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
let over_grid = h.state().grid.rect.unwrap().center();
|
||||
h.input_mut().events.push(Event::PointerMoved(over_grid));
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn zoom(h: &Harness) -> u16 {
|
||||
h.state().session.active().unwrap().wb.active().unwrap().zoom
|
||||
}
|
||||
|
||||
fn set_zoom(h: &mut Harness, percent: u16) {
|
||||
h.state_mut().run("view.zoom", json!({"percent": percent})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Turns the wheel `turns` notches at a time with Ctrl held (up zooms in), each turn `frames`
|
||||
/// frames after the last, then lets egui's easing play out.
|
||||
fn wheel_turns(h: &mut Harness, turns: &[f32], frames: usize) {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
for &t in turns {
|
||||
let delta = vec2(0.0, t);
|
||||
h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND });
|
||||
h.run_steps(frames);
|
||||
}
|
||||
h.run_steps(60);
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn wheel(h: &mut Harness, notches: f32) {
|
||||
wheel_turns(h, &[notches], 1);
|
||||
}
|
||||
|
||||
/// The harness turns egui's animations off (and the zoom glide follows that); this turns them on.
|
||||
fn animated() -> Harness {
|
||||
let mut h = harness();
|
||||
h.ctx.all_styles_mut(|s| s.animation_time = 0.1);
|
||||
h.step();
|
||||
h
|
||||
}
|
||||
|
||||
/// Turns the wheel `notches` with Ctrl held and records the zoom over the next `frames` frames.
|
||||
fn turn_and_watch(h: &mut Harness, notches: f32, frames: usize) -> Vec<u16> {
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
let delta = vec2(0.0, notches);
|
||||
h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND });
|
||||
(0..frames)
|
||||
.map(|_| {
|
||||
h.step();
|
||||
zoom(h)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_animations_the_zoom_glides_to_the_step() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 40);
|
||||
assert!(seen.windows(2).all(|w| w[0] <= w[1]), "only ever towards the step: {seen:?}");
|
||||
let between = seen.iter().filter(|&&z| z > 100 && z < 120).count();
|
||||
assert!(between >= 3, "on its way over several frames: {seen:?}");
|
||||
assert_eq!(seen.last(), Some(&120), "and exactly on the step: {seen:?}");
|
||||
let seen = turn_and_watch(&mut h, -1.0, 40);
|
||||
assert!(seen.windows(2).all(|w| w[0] >= w[1]) && seen.iter().any(|&z| z > 100 && z < 120), "{seen:?}");
|
||||
assert_eq!(seen.last(), Some(&100), "{seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notch_during_a_glide_steps_on_from_where_it_is_going() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "still gliding: {seen:?}");
|
||||
turn_and_watch(&mut h, 1.0, 60);
|
||||
assert_eq!(zoom(&h), 140);
|
||||
let seen = turn_and_watch(&mut h, -1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 120 && z < 140), "{seen:?}");
|
||||
turn_and_watch(&mut h, -1.0, 60);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoom_set_another_way_stops_a_glide() {
|
||||
let mut h = animated();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 6);
|
||||
assert!(seen.last().is_some_and(|&z| z > 100 && z < 120), "{seen:?}");
|
||||
h.state_mut().run("view.zoom", json!({"percent": 150})).unwrap();
|
||||
h.run_steps(60);
|
||||
assert_eq!(zoom(&h), 150);
|
||||
// Setting the percentage the glide had got to doesn't start it again.
|
||||
let stopped_at = seen.last().copied().unwrap();
|
||||
set_zoom(&mut h, stopped_at);
|
||||
h.run_steps(60);
|
||||
assert_eq!(zoom(&h), stopped_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_view_animations_off_a_notch_steps_at_once() {
|
||||
let mut h = animated();
|
||||
h.state_mut().run("view.animations", json!({"on": false})).unwrap();
|
||||
let seen = turn_and_watch(&mut h, 1.0, 40);
|
||||
assert!(seen.iter().all(|&z| z == 100 || z == 120), "no percentages in between: {seen:?}");
|
||||
assert_eq!(seen.last(), Some(&120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notch_out_returns_to_the_zoom_before_the_notch_in() {
|
||||
let mut h = harness();
|
||||
assert_eq!(zoom(&h), 100);
|
||||
// Multiplying the rounded zoom by egui's eased factor every frame drifted: 100 → 122 → 101 → 123 → 102.
|
||||
for _ in 0..4 {
|
||||
wheel(&mut h, 1.0);
|
||||
assert_eq!(zoom(&h), 120);
|
||||
wheel(&mut h, -1.0);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
for expect in [120, 140, 170] {
|
||||
wheel(&mut h, 1.0);
|
||||
assert_eq!(zoom(&h), expect);
|
||||
}
|
||||
wheel(&mut h, -3.0);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel(&mut h, -2.0);
|
||||
assert_eq!(zoom(&h), 70);
|
||||
// Notches turned quickly, each before the last one has eased in.
|
||||
wheel_turns(&mut h, &[1.0, 1.0, 1.0, 1.0], 3);
|
||||
assert_eq!(zoom(&h), 140);
|
||||
wheel_turns(&mut h, &[-1.0, -1.0], 3);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_of_a_notch_add_up_to_whole_steps() {
|
||||
// A fine-grained wheel (or a Windows touchpad pinch) sends a notch in pieces, not always evenly.
|
||||
let mut h = harness();
|
||||
wheel_turns(&mut h, &[0.25, 0.25, 0.25], 4);
|
||||
assert_eq!(zoom(&h), 120, "three quarters of a notch step");
|
||||
wheel_turns(&mut h, &[-0.3, -0.4, -0.3], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel_turns(&mut h, &[0.25, 0.25, 0.25, 0.25, 0.3, 0.4, 0.3], 4);
|
||||
assert_eq!(zoom(&h), 140, "one step per notch, however it's divided");
|
||||
wheel_turns(&mut h, &[-0.5, -0.25, -0.25, -0.25, -0.25, -0.5], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
wheel_turns(&mut h, &[0.5], 4);
|
||||
assert_eq!(zoom(&h), 100, "half a notch doesn't step");
|
||||
// What's left of a notch is forgotten after a pause (`wheel_turns` waits a second) rather
|
||||
// than adding up with the next turn.
|
||||
wheel_turns(&mut h, &[0.4], 4);
|
||||
assert_eq!(zoom(&h), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_a_zoom_set_another_way_a_notch_goes_to_the_next_step_or_100() {
|
||||
let mut h = harness();
|
||||
for (from, notches, to) in
|
||||
[(112, 1.0, 120), (112, -1.0, 100), (121, -1.0, 100), (99, 1.0, 100), (101, -1.0, 100), (385, 1.0, 400), (400, 1.0, 400), (11, -1.0, 10)]
|
||||
{
|
||||
set_zoom(&mut h, from);
|
||||
wheel(&mut h, notches);
|
||||
assert_eq!(zoom(&h), to, "{from}% by {notches}");
|
||||
}
|
||||
// Two notches out from 121%: 100%, then 85%.
|
||||
set_zoom(&mut h, 121);
|
||||
wheel(&mut h, -2.0);
|
||||
assert_eq!(zoom(&h), 85);
|
||||
}
|
||||
|
|
@ -8,6 +8,9 @@ use gridcraft_model::{Cell, NumFmt, Sheet, StyleId, Workbook};
|
|||
|
||||
use crate::{CsvOptions, IoError};
|
||||
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
|
||||
const CP1252: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
|
|
@ -187,10 +190,31 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
|
|||
/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives
|
||||
/// each cell's field text: the caller decides how values look (Save As CSV writes them as
|
||||
/// displayed, number formats applied); quoting is done here.
|
||||
pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef) -> String) -> Vec<u8> {
|
||||
///
|
||||
/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the
|
||||
/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
|
||||
/// An export that would pass the cap is refused before anything is allocated, or as soon as the
|
||||
/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in
|
||||
/// its place.
|
||||
pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
|
||||
write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text)
|
||||
}
|
||||
|
||||
/// [`write_csv`] with the output cap `limit` (bytes) given.
|
||||
pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
|
||||
let delim = if delimiter == 0 { ',' } else { delimiter as char };
|
||||
let mut out = String::new();
|
||||
let Some(r) = sheet.used_range() else { return Vec::new() };
|
||||
let Some(r) = sheet.used_range() else { return Ok(Vec::new()) };
|
||||
let too_large =
|
||||
|what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20));
|
||||
// Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower
|
||||
// bound on the output that needs no cell text.
|
||||
let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1);
|
||||
let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2);
|
||||
let least = rows.saturating_mul(per_row);
|
||||
if least > limit as u64 {
|
||||
return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20)));
|
||||
}
|
||||
for row in r.start.row..=r.end.row {
|
||||
for col in r.start.col..=r.end.col {
|
||||
if col > r.start.col {
|
||||
|
|
@ -206,6 +230,9 @@ pub fn write_csv(sheet: &Sheet, delimiter: u8, mut cell_text: impl FnMut(CellRef
|
|||
}
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
}
|
||||
out.into_bytes()
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ use crate::read::Ctx;
|
|||
use crate::tables::EMU_PER_PX;
|
||||
use crate::xml::{El, esc, esc_attr};
|
||||
|
||||
mod ink;
|
||||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
fn emu_px(v: Option<i64>) -> f32 {
|
||||
|
|
@ -195,6 +197,9 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(
|
|||
|
||||
fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
|
||||
let sp = obj.child("spPr")?;
|
||||
if let Some(shape) = ink::read(obj, anchor) {
|
||||
return Some(shape);
|
||||
}
|
||||
let prst = sp.child("prstGeom").and_then(|g| g.attr("prst")).unwrap_or("rect");
|
||||
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
|
||||
let ln = sp.child("ln");
|
||||
|
|
@ -358,6 +363,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
|||
let (open, close) = anchor_open(sheet, &sh.anchor);
|
||||
close_tag = close;
|
||||
s.push_str(&open);
|
||||
if let Some(xml) = ink::write(sh, theme, id) {
|
||||
s.push_str(&xml);
|
||||
s.push_str("<xdr:clientData/>");
|
||||
s.push_str(close_tag);
|
||||
continue;
|
||||
}
|
||||
let (prst, tx) = match sh.kind {
|
||||
ShapeKind::Rectangle => ("rect", false),
|
||||
ShapeKind::RoundedRectangle => ("roundRect", false),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
//! 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 super::*;
|
||||
|
||||
const MAX_POINTS: usize = 20_000;
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
|
||||
fn bounded(v: f64) -> bool {
|
||||
v.is_finite() && v.abs() <= MAX_COORD
|
||||
}
|
||||
|
||||
fn only_children(e: &El, names: &[&str]) -> bool {
|
||||
e.children.iter().all(|c| names.contains(&c.name.as_str()))
|
||||
}
|
||||
|
||||
fn only_attrs(e: &El, names: &[&str]) -> bool {
|
||||
e.attrs.iter().all(|(k, _)| names.contains(&k.as_str()))
|
||||
}
|
||||
|
||||
fn one<'a>(e: &'a El, name: &str) -> Option<&'a El> {
|
||||
let mut found = e.children.iter().filter(|c| c.name == name);
|
||||
let first = found.next()?;
|
||||
found.next().is_none().then_some(first)
|
||||
}
|
||||
|
||||
/// 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"])
|
||||
|| obj.attrs.iter().any(|(_, v)| !v.is_empty())
|
||||
|| obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|e| e.flag("txBox", false))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let sp = one(obj, "spPr")?;
|
||||
if !only_children(sp, &["xfrm", "custGeom", "noFill", "ln"]) || !sp.attrs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let no_fill = one(sp, "noFill")?;
|
||||
if !no_fill.attrs.is_empty() || !no_fill.children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let xf = one(sp, "xfrm")?;
|
||||
if !xf.attrs.is_empty() || !only_children(xf, &["off", "ext"]) {
|
||||
return None;
|
||||
}
|
||||
let off = one(xf, "off")?;
|
||||
if off.attr_i64("x")? != 0 || off.attr_i64("y")? != 0 {
|
||||
return None;
|
||||
}
|
||||
let ext = one(xf, "ext")?;
|
||||
let ew = ext.attr_i64("cx")?;
|
||||
let eh = ext.attr_i64("cy")?;
|
||||
if ew <= 0 || eh <= 0 || anchor.width <= 0.0 || anchor.height <= 0.0 || !bounded(anchor.width as f64) || !bounded(anchor.height as f64) {
|
||||
return None;
|
||||
}
|
||||
let same_extent =
|
||||
|emu: i64, extent: f32| (emu as f64 / EMU_PER_PX - extent as f64).abs() <= 0.001_f64.max(extent as f64 * f32::EPSILON as f64 * 2.0);
|
||||
if !same_extent(ew, anchor.width) || !same_extent(eh, anchor.height) {
|
||||
return None;
|
||||
}
|
||||
let geom = one(sp, "custGeom")?;
|
||||
if !geom.attrs.is_empty() || !only_children(geom, &["avLst", "gdLst", "ahLst", "cxnLst", "rect", "pathLst"]) {
|
||||
return None;
|
||||
}
|
||||
// Text bounds have no effect on this unfilled geometry, which has no caption.
|
||||
if geom.children.iter().any(|c| !matches!(c.name.as_str(), "pathLst" | "rect") && (!c.children.is_empty() || !c.attrs.is_empty())) {
|
||||
return None;
|
||||
}
|
||||
if geom.kids("rect").count() > 1 || geom.kids("rect").any(|r| !r.children.is_empty() || !only_attrs(r, &["l", "t", "r", "b"])) {
|
||||
return None;
|
||||
}
|
||||
let paths = one(geom, "pathLst")?;
|
||||
if paths.children.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let path = one(paths, "path")?;
|
||||
let w = path.attr_i64("w")?;
|
||||
let h = path.attr_i64("h")?;
|
||||
if w <= 0
|
||||
|| h <= 0
|
||||
|| path.attr("fill") != Some("none")
|
||||
|| !only_attrs(path, &["w", "h", "fill", "stroke", "extrusionOk"])
|
||||
|| path.attr("stroke").is_some_and(|_| path.attr_bool("stroke") != Some(true))
|
||||
|| path.attr("extrusionOk").is_some_and(|_| path.attr_bool("extrusionOk") != Some(false))
|
||||
|| !(2..=MAX_POINTS).contains(&path.children.len())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let ln = one(sp, "ln")?;
|
||||
if !only_attrs(ln, &["w", "cap", "cmpd", "algn"])
|
||||
|| ln.attr("cap") != Some("flat")
|
||||
|| ln.attr("cmpd").is_some_and(|v| v != "sng")
|
||||
|| ln.attr("algn").is_some_and(|v| v != "ctr")
|
||||
|| !only_children(ln, &["solidFill", "miter"])
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width_emu = ln.attr_i64("w")?;
|
||||
let unit = EMU_PER_PX as i64;
|
||||
if width_emu % unit != 0 || !(1..=40).contains(&(width_emu / unit)) {
|
||||
return None;
|
||||
}
|
||||
let width = width_emu / unit;
|
||||
let join = one(ln, "miter")?;
|
||||
if !join.attrs.is_empty() || !join.children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fill = one(ln, "solidFill")?;
|
||||
if fill.children.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let color = one(fill, "srgbClr")?;
|
||||
if !only_attrs(color, &["val"]) || !only_children(color, &["alpha"]) {
|
||||
return None;
|
||||
}
|
||||
let expected_alpha = if width > 8 { 45_000 } else { 100_000 };
|
||||
if color.children.is_empty() {
|
||||
if expected_alpha != 100_000 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let alpha = one(color, "alpha")?;
|
||||
if !only_attrs(alpha, &["val"]) || !alpha.children.is_empty() || alpha.attr_i64("val")? != expected_alpha {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let line = Color::from_hex(color.attr("val")?)?;
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
let pt = one(command, "pt")?;
|
||||
if !only_attrs(pt, &["x", "y"]) || !pt.children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let x = pt.attr_i64("x")? as f64 * anchor.width as f64 / w as f64;
|
||||
let y = pt.attr_i64("y")? as f64 * anchor.height as f64 / h as f64;
|
||||
if !bounded(x) || !bounded(y) {
|
||||
return None;
|
||||
}
|
||||
if i != 0 {
|
||||
text.push(';');
|
||||
}
|
||||
let _ = write!(text, "{},{}", x as f32, y as f32);
|
||||
}
|
||||
Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() })
|
||||
}
|
||||
|
||||
/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful
|
||||
/// result is complete before it is appended, so malformed points never yield half a stroke.
|
||||
pub(super) fn write(shape: &Shape, theme: &Theme, id: usize) -> Option<String> {
|
||||
if shape.kind != ShapeKind::Ink || shape.format.no_line {
|
||||
return None;
|
||||
}
|
||||
let width = match shape.fill {
|
||||
Color::Rgb(rgb) => ((rgb >> 16) & 0xff).max(1),
|
||||
_ => 2,
|
||||
};
|
||||
if !(1..=40).contains(&width) {
|
||||
return None;
|
||||
}
|
||||
let a = &shape.anchor;
|
||||
if !bounded(a.width as f64) || !bounded(a.height as f64) || a.width <= 0.0 || a.height <= 0.0 || !a.dx.is_finite() || !a.dy.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let w = (a.width as f64 * EMU_PER_PX).round().max(1.0) as i64;
|
||||
let h = (a.height as f64 * EMU_PER_PX).round().max(1.0) as i64;
|
||||
let mut points = Vec::new();
|
||||
for point in shape.text.split(';') {
|
||||
if points.len() == MAX_POINTS {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = point.split_once(',')?;
|
||||
let (x, y) = (x.parse::<f64>().ok()?, y.parse::<f64>().ok()?);
|
||||
if !bounded(x) || !bounded(y) {
|
||||
return None;
|
||||
}
|
||||
points.push(((x * EMU_PER_PX).round() as i64, (y * EMU_PER_PX).round() as i64));
|
||||
}
|
||||
if points.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let [r, g, b] = shape.line.resolve(theme)?;
|
||||
let mut xml = format!(
|
||||
"<xdr:sp macro=\"\" textlink=\"\"><xdr:nvSpPr><xdr:cNvPr id=\"{id}\" name=\"Ink {id}\"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr>{}<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:pathLst><a:path w=\"{w}\" h=\"{h}\" fill=\"none\">",
|
||||
xfrm(a, "a:xfrm")
|
||||
);
|
||||
for (i, (x, y)) in points.iter().enumerate() {
|
||||
let tag = if i == 0 { "moveTo" } else { "lnTo" };
|
||||
let _ = write!(xml, "<a:{tag}><a:pt x=\"{x}\" y=\"{y}\"/></a:{tag}>");
|
||||
}
|
||||
let _ = write!(
|
||||
xml,
|
||||
"</a:path></a:pathLst></a:custGeom><a:noFill/><a:ln w=\"{}\" cap=\"flat\"><a:solidFill><a:srgbClr val=\"{r:02X}{g:02X}{b:02X}\">",
|
||||
width as i64 * EMU_PER_PX as i64
|
||||
);
|
||||
if width > 8 {
|
||||
xml.push_str("<a:alpha val=\"45000\"/>");
|
||||
}
|
||||
xml.push_str("</a:srgbClr></a:solidFill><a:miter/></a:ln></xdr:spPr></xdr:sp>");
|
||||
Some(xml)
|
||||
}
|
||||
|
|
@ -45,8 +45,9 @@ use gridcraft_core::DateSystem;
|
|||
use gridcraft_model::Workbook;
|
||||
|
||||
pub use crypto::{DecryptError, check_encryption, decrypt};
|
||||
pub use csv::{read_csv, write_csv};
|
||||
pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited};
|
||||
pub use ods::read_ods;
|
||||
pub use passthrough::rebase_links;
|
||||
pub use xls::read_xls;
|
||||
pub use xlsb::read_xlsb;
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
//! drawings, charts, tables, PivotTables, comments, slicers…) is not kept: it would point at
|
||||
//! content that no longer exists in the same form.
|
||||
//! - External relationship targets (the path of a linked workbook) are kept as text and never
|
||||
//! opened, followed or refreshed.
|
||||
//! opened, followed or refreshed. A relative path to a linked workbook is rewritten when the
|
||||
//! workbook is saved into another folder ([`rebase_links`]), so it still names the same file.
|
||||
//! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it.
|
||||
//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept
|
||||
//! as they were, not adjusted to inserted or deleted rows.
|
||||
|
|
@ -25,9 +26,10 @@
|
|||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook};
|
||||
use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml, Sheet, Visibility, Workbook};
|
||||
use quick_xml::events::attributes::Attribute;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use quick_xml::name::QName;
|
||||
|
|
@ -137,6 +139,10 @@ const REWRITTEN: &[&str] = &[
|
|||
"powerpivotdata",
|
||||
];
|
||||
|
||||
/// Relationship kinds a chart sheet reaches that are otherwise rewritten: its drawing, chart and
|
||||
/// their style, colour and shape parts are kept byte for byte (the chart sheet is not modelled).
|
||||
const CHART_SHEET_GRAPH: &[&str] = &["chartsheet", "drawing", "chart", "chartex", "chartstyle", "chartcolorstyle", "chartusershapes"];
|
||||
|
||||
/// Part-name prefixes (lower case) GridCraft's writer never produces: kept parts there keep their
|
||||
/// original names. Anything else is renamed so it can't collide with written parts.
|
||||
const OWN_DIRS: &[&str] = &[
|
||||
|
|
@ -201,6 +207,21 @@ fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result<Vec<PreservedRel>, String> {
|
|||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
/// The `<sheet>` entries of the workbook that are chart sheets, each with the number of worksheets
|
||||
/// (model sheets) before it.
|
||||
fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option<usize>]) -> Vec<(usize, &'a xml::El)> {
|
||||
let mut out = vec![];
|
||||
let mut worksheets = 0usize;
|
||||
for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() {
|
||||
if file_to_model.get(i).copied().flatten().is_some() {
|
||||
worksheets += 1;
|
||||
} else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) {
|
||||
out.push((worksheets, se));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Collects kept content while a package is read.
|
||||
pub struct KeptReader {
|
||||
on: bool,
|
||||
|
|
@ -257,11 +278,12 @@ impl KeptReader {
|
|||
|
||||
/// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the
|
||||
/// graph reaches rewritten content, a missing or damaged part, or a size limit.
|
||||
fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet<String>) -> Result<Vec<PreservedPart>, String> {
|
||||
fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet<String>, through: &[&str]) -> Result<Vec<PreservedPart>, String> {
|
||||
if root.external {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if rewritten(root) {
|
||||
let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type)));
|
||||
if blocked(root) {
|
||||
return Err(format!("it refers to {}", kind(&root.rel_type)));
|
||||
}
|
||||
let mut parts = vec![];
|
||||
|
|
@ -291,7 +313,7 @@ impl KeptReader {
|
|||
}
|
||||
let rels = raw_rels(cx, &name)?;
|
||||
for r in rels.iter().filter(|r| !r.external) {
|
||||
if rewritten(r) {
|
||||
if blocked(r) {
|
||||
return Err(format!("it refers to {}", kind(&r.rel_type)));
|
||||
}
|
||||
queue.push_back((r.target.clone(), depth + 1));
|
||||
|
|
@ -305,10 +327,22 @@ impl KeptReader {
|
|||
|
||||
/// Keeps a set of relationships (all or nothing) and the parts behind them.
|
||||
fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet<String>) -> Result<(), String> {
|
||||
self.keep_through(cx, p, roots, seen, &[])
|
||||
}
|
||||
|
||||
/// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`.
|
||||
fn keep_through(
|
||||
&mut self,
|
||||
cx: &mut Ctx<'_>,
|
||||
p: &mut Preserved,
|
||||
roots: &[PreservedRel],
|
||||
seen: &mut HashSet<String>,
|
||||
through: &[&str],
|
||||
) -> Result<(), String> {
|
||||
let mut new_parts: Vec<PreservedPart> = vec![];
|
||||
let mut scratch = seen.clone();
|
||||
for r in roots {
|
||||
let parts = self.closure(cx, r, &scratch)?;
|
||||
let parts = self.closure(cx, r, &scratch, through)?;
|
||||
scratch.extend(parts.iter().map(|q| normalize(&q.name)));
|
||||
new_parts.extend(parts);
|
||||
}
|
||||
|
|
@ -323,8 +357,12 @@ impl KeptReader {
|
|||
}
|
||||
|
||||
/// Package-root and workbook-level content.
|
||||
pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option<Arc<Preserved>> {
|
||||
pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option<usize>]) -> Option<Arc<Preserved>> {
|
||||
if !self.on {
|
||||
let wb_rels = raw_rels(cx, wb_part).unwrap_or_default();
|
||||
for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) {
|
||||
cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or("")));
|
||||
}
|
||||
if self.strict {
|
||||
cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save");
|
||||
}
|
||||
|
|
@ -351,6 +389,25 @@ impl KeptReader {
|
|||
cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target));
|
||||
}
|
||||
}
|
||||
// Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the
|
||||
// drawing, chart, style and colour parts behind it.
|
||||
for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) {
|
||||
let name = se.attr("name").unwrap_or("").to_string();
|
||||
let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue };
|
||||
match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) {
|
||||
Ok(()) => p.sheets.push(PreservedSheet {
|
||||
name,
|
||||
visibility: match se.attr("state") {
|
||||
Some("hidden") => Visibility::Hidden,
|
||||
Some("veryHidden") => Visibility::VeryHidden,
|
||||
_ => Visibility::Visible,
|
||||
},
|
||||
rel_id: r.id.clone(),
|
||||
before: worksheets,
|
||||
}),
|
||||
Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")),
|
||||
}
|
||||
}
|
||||
// Links to external workbooks: `[1]` in formulas is the position in <externalReferences>,
|
||||
// so they are kept all together or not at all.
|
||||
let ids: Vec<&str> =
|
||||
|
|
@ -869,18 +926,165 @@ fn remap(x: &str, ids: &HashMap<String, String>) -> Option<String> {
|
|||
map_rel_ids(x, &mut |id| ids.get(id).cloned())
|
||||
}
|
||||
|
||||
/// Writes the workbook's kept parts and relationships; returns the workbook XML elements that
|
||||
/// go right after `<sheets>` (`<externalReferences>`).
|
||||
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> String {
|
||||
let Some(p) = wb.preserved.as_deref() else { return String::new() };
|
||||
let names = write_parts(out, &p.parts);
|
||||
let ids = attach(wb_rels, &p.rels, &names);
|
||||
/// A kept chart sheet being written: its `<sheet>` entry's fields and its new relationship id.
|
||||
pub struct KeptSheet {
|
||||
before: usize,
|
||||
name: String,
|
||||
visibility: Visibility,
|
||||
rid: String,
|
||||
}
|
||||
|
||||
/// The workbook's kept content as written.
|
||||
#[derive(Default)]
|
||||
pub struct WorkbookKept {
|
||||
/// Workbook XML elements that go right after `<sheets>` (`<externalReferences>`).
|
||||
pub elements: String,
|
||||
sheets: Vec<KeptSheet>,
|
||||
}
|
||||
|
||||
impl WorkbookKept {
|
||||
/// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets.
|
||||
pub fn file_index(&self, i: usize) -> usize {
|
||||
i + self.sheets.iter().filter(|s| s.before <= i).count()
|
||||
}
|
||||
|
||||
/// The `<sheet>` entries of the chart sheets that go right before the model's sheet `i`
|
||||
/// (`i` = the number of sheets for those that come last); `id` numbers them from `first_id`.
|
||||
pub fn sheet_entries(&self, i: usize, last: bool, mut id: usize) -> (String, usize) {
|
||||
let mut x = String::new();
|
||||
for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) {
|
||||
let state = match s.visibility {
|
||||
Visibility::Visible => "",
|
||||
Visibility::Hidden => " state=\"hidden\"",
|
||||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = s.name.chars().take(31).collect();
|
||||
let _ = write!(x, "<sheet name=\"{}\" sheetId=\"{id}\"{state} r:id=\"{}\"/>", esc_attr(&name), esc_attr(&s.rid));
|
||||
id += 1;
|
||||
}
|
||||
(x, id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The part names reachable from `root` through kept parts' relationships (normalized).
|
||||
fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet<String> {
|
||||
let by_name: HashMap<String, &PreservedPart> = p.parts.iter().map(|q| (normalize(&q.name), q)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut queue = VecDeque::from([normalize(&root.target)]);
|
||||
while let Some(n) = queue.pop_front() {
|
||||
if !seen.insert(n.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = by_name.get(&n) {
|
||||
queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target)));
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Every formula (`<c:f>`) of a chart part, depth first.
|
||||
fn chart_formulas(el: &xml::El, out: &mut Vec<String>) {
|
||||
if el.name == "f" && !el.text.trim().is_empty() {
|
||||
out.push(el.text.trim().to_string());
|
||||
}
|
||||
for c in &el.children {
|
||||
chart_formulas(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to
|
||||
/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out.
|
||||
pub(crate) fn sheet_names_in(formula: &str) -> Vec<String> {
|
||||
let mut names = vec![];
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let at = |i: usize| chars.get(i).copied();
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
let mut name = String::new();
|
||||
let start = i;
|
||||
if c == '\'' {
|
||||
i += 1;
|
||||
while let Some(c) = at(i) {
|
||||
if c == '\'' {
|
||||
if at(i + 1) == Some('\'') {
|
||||
name.push('\'');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
name.push(c);
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) {
|
||||
name.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if at(i) == Some('!') {
|
||||
if !name.is_empty() && !name.starts_with('[') {
|
||||
names.push(name);
|
||||
}
|
||||
i += 1;
|
||||
} else if i == start {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets
|
||||
/// that exist under the same names (the chart is kept as it was, so a series on a deleted or
|
||||
/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name.
|
||||
fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool {
|
||||
let same = |a: &str, b: &str| a.chars().take(31).collect::<String>().eq_ignore_ascii_case(&b.chars().take(31).collect::<String>());
|
||||
if wb.sheets.iter().any(|w| same(&w.name, &s.name)) {
|
||||
return false;
|
||||
}
|
||||
let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false };
|
||||
let parts = reachable(p, root);
|
||||
let mut formulas = vec![];
|
||||
for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) {
|
||||
if let Ok(x) = xml::parse(&q.data) {
|
||||
chart_formulas(&x, &mut formulas);
|
||||
}
|
||||
}
|
||||
formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n)))
|
||||
}
|
||||
|
||||
/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back
|
||||
/// (see [`chart_sheet_usable`]) are left out with the parts only they reach.
|
||||
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept {
|
||||
let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() };
|
||||
let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s));
|
||||
let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id);
|
||||
let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect();
|
||||
// Parts reached only by dropped chart sheets go with them.
|
||||
let mut gone: HashSet<String> = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect();
|
||||
if !gone.is_empty() {
|
||||
let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r));
|
||||
for n in others {
|
||||
gone.remove(&n);
|
||||
}
|
||||
}
|
||||
let parts: Vec<PreservedPart> = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect();
|
||||
let rels: Vec<PreservedRel> = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect();
|
||||
let names = write_parts(out, &parts);
|
||||
let ids = attach(wb_rels, &rels, &names);
|
||||
for r in &p.package_rels {
|
||||
if let Some(target) = rel_target(r, &names) {
|
||||
out.kept.root.push((r.rel_type.clone(), target, r.external));
|
||||
}
|
||||
}
|
||||
p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect()
|
||||
WorkbookKept {
|
||||
elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(),
|
||||
sheets: live
|
||||
.iter()
|
||||
.filter_map(|s| Some(KeptSheet { before: s.before, name: s.name.clone(), visibility: s.visibility, rid: ids.get(&s.rel_id)?.clone() }))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`).
|
||||
|
|
@ -958,3 +1162,136 @@ impl<'a> SheetKept<'a> {
|
|||
s
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- linked workbooks
|
||||
|
||||
/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked
|
||||
/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel
|
||||
/// does on Save As, or made absolute when no relative path exists (another drive). `None` when
|
||||
/// nothing changes.
|
||||
pub fn rebase_links(wb: &Workbook, to: &Path) -> Option<Workbook> {
|
||||
let p = wb.preserved.as_deref()?;
|
||||
let from = Folder::of(wb.source_dir.as_deref()?)?;
|
||||
let to = Folder::of(to)?;
|
||||
if from == to {
|
||||
return None;
|
||||
}
|
||||
let mut parts = p.parts.clone();
|
||||
let mut changed = false;
|
||||
for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) {
|
||||
if r.external
|
||||
&& is_kind(r, &["externalLinkPath"])
|
||||
&& let Some(target) = rebase(&r.target, &from, &to)
|
||||
{
|
||||
r.target = target;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() }));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// An absolute folder, lexically normalized.
|
||||
#[derive(PartialEq)]
|
||||
struct Folder {
|
||||
/// Drive or share and root separator (`/`, `C:\`, `\\server\share\`).
|
||||
root: String,
|
||||
names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Folder {
|
||||
fn of(dir: &Path) -> Option<Folder> {
|
||||
if !dir.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let mut f = Folder { root: String::new(), names: vec![] };
|
||||
for c in dir.components() {
|
||||
match c {
|
||||
Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
f.names.pop();
|
||||
}
|
||||
Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
Some(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an
|
||||
/// absolute path or URL, or a path that climbs above the root.
|
||||
fn rebase(target: &str, from: &Folder, to: &Folder) -> Option<String> {
|
||||
if !is_relative_path(target) {
|
||||
return None;
|
||||
}
|
||||
// Each name as the file system knows it, and as it is written in a target. Names taken from
|
||||
// the original target keep their spelling.
|
||||
let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect();
|
||||
for seg in target.split(['/', '\\']) {
|
||||
match seg {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
path.pop()?;
|
||||
}
|
||||
_ => path.push((decode_name(seg), seg.to_string())),
|
||||
}
|
||||
}
|
||||
let folders = path.len().checked_sub(1)?;
|
||||
if !from.root.eq_ignore_ascii_case(&to.root) {
|
||||
let sep = if from.root.contains('\\') { "\\" } else { "/" };
|
||||
let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect();
|
||||
return Some(format!("file:///{}{}", from.root, names.join(sep)));
|
||||
}
|
||||
let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count();
|
||||
let up = "../".repeat(to.names.len().saturating_sub(common));
|
||||
let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect();
|
||||
Some(format!("{up}{}", down.join("/")))
|
||||
}
|
||||
|
||||
/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`).
|
||||
fn is_relative_path(target: &str) -> bool {
|
||||
let scheme = target.split_once(':').is_some_and(|(s, _)| {
|
||||
s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|
||||
});
|
||||
!target.is_empty() && !target.starts_with(['/', '\\']) && !scheme
|
||||
}
|
||||
|
||||
/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path
|
||||
/// can't hold are percent-encoded, other characters are written as they are.
|
||||
fn encode_name(name: &str) -> String {
|
||||
let mut s = String::with_capacity(name.len());
|
||||
for c in name.chars() {
|
||||
if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) {
|
||||
s.push(c);
|
||||
} else {
|
||||
let _ = write!(s, "%{:02X}", c as u32);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8.
|
||||
fn decode_name(seg: &str) -> String {
|
||||
let b = seg.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
match hex {
|
||||
Some(byte) if c == b'%' => {
|
||||
out.push(byte);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| seg.to_string())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ pub struct Ctx<'a> {
|
|||
pub num_fmts: std::collections::HashMap<u32, String>,
|
||||
/// Value metadata blocks, indexed by the worksheet's one-based `vm` attribute.
|
||||
pub cell_pictures: Vec<Option<Arc<CellPicture>>>,
|
||||
/// x14 conditional-format ids of the current sheet's data bars, with the index of the rule in `cond_formats`.
|
||||
pub cf_ext_ids: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
|
|
@ -79,6 +81,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
next_id: 1,
|
||||
num_fmts: Default::default(),
|
||||
cell_pictures: vec![],
|
||||
cf_ext_ids: vec![],
|
||||
};
|
||||
let root_rels = cx.pkg.rels("")?;
|
||||
let wb_part = find_rel(&root_rels, "officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "xl/workbook.xml".into());
|
||||
|
|
@ -120,6 +123,10 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
if let Some(x) = cx.optional_xml(&r.target)? {
|
||||
let StylesIn { xfs, dxfs, palette, named, num_fmts } = read_styles(&x, &checkboxes);
|
||||
cx.num_fmts = num_fmts;
|
||||
// Cell format 0 is the Normal style: its font is what every unformatted cell shows.
|
||||
if let Some(normal) = xfs.first() {
|
||||
cx.styles = StyleTable::with_default(Style { font: normal.font.clone(), ..Style::default() });
|
||||
}
|
||||
cx.xf_map = xfs.into_iter().enumerate().map(|(i, s)| if i == 0 { StyleId::DEFAULT } else { cx.styles.intern(s) }).collect();
|
||||
cx.dxfs = dxfs;
|
||||
cx.palette = palette;
|
||||
|
|
@ -201,6 +208,11 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
file_to_model.push(None);
|
||||
continue;
|
||||
};
|
||||
if rel.kind == "chartsheet" {
|
||||
// Kept as it is and written back in place (see `passthrough`); nothing to model.
|
||||
file_to_model.push(None);
|
||||
continue;
|
||||
}
|
||||
if rel.kind != "worksheet" {
|
||||
cx.warn(format!("sheet \"{name}\" is a {} and was skipped", rel.kind));
|
||||
file_to_model.push(None);
|
||||
|
|
@ -327,7 +339,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")),
|
||||
}
|
||||
}
|
||||
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml);
|
||||
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml, &file_to_model);
|
||||
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
|
||||
wb.apply_name_call_case();
|
||||
wb.styles = cx.styles;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
|
|||
drop(bytes);
|
||||
|
||||
let mut fit_to_page = false;
|
||||
cx.cf_ext_ids.clear();
|
||||
for e in &root.children {
|
||||
match e.name.as_str() {
|
||||
"sheetPr" => {
|
||||
|
|
@ -596,6 +597,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
|||
let formulas: Vec<String> = r.kids("formula").map(|f| from_file(f.text.trim())).collect();
|
||||
let f0 = formulas.first().cloned().unwrap_or_default();
|
||||
let text = r.attr("text").unwrap_or("").to_string();
|
||||
// The x14 id that links a data bar to its Excel 2010 extension (solid fill lives there).
|
||||
let ext_id =
|
||||
r.child("extLst").and_then(|l| l.kids("ext").find_map(|x| x.child("id"))).map(|i| i.text.trim().to_string()).filter(|i| !i.is_empty());
|
||||
let rule = match r.attr("type").unwrap_or("") {
|
||||
"cellIs" => CfRule::CellIs { op: cf_op(r.attr("operator")), a: f0, b: formulas.get(1).cloned(), style },
|
||||
"expression" => CfRule::Expression { formula: f0, style },
|
||||
|
|
@ -649,6 +653,9 @@ fn read_cf(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
|||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(id) = ext_id {
|
||||
cx.cf_ext_ids.push((id, sheet.cond_formats.len()));
|
||||
}
|
||||
sheet.cond_formats.push(CondFormat {
|
||||
ranges: ranges.clone(),
|
||||
rule,
|
||||
|
|
@ -956,7 +963,23 @@ fn read_ext(cx: &mut Ctx<'_>, e: &El, sheet: &mut Sheet) {
|
|||
}
|
||||
}
|
||||
}
|
||||
"conditionalFormattings" => cx.warn("some Excel 2010 conditional format extensions were skipped"),
|
||||
"conditionalFormattings" => {
|
||||
let mut skipped = false;
|
||||
for r in c.kids("conditionalFormatting").flat_map(|f| f.kids("cfRule")) {
|
||||
let idx = r.attr("id").and_then(|id| cx.cf_ext_ids.iter().find(|(i, _)| i == id)).map(|(_, n)| *n);
|
||||
match (idx.and_then(|n| sheet.cond_formats.get_mut(n)), r.child("dataBar")) {
|
||||
(Some(CondFormat { rule: CfRule::DataBar { gradient, .. }, .. }), Some(db)) => {
|
||||
*gradient = db.flag("gradient", true);
|
||||
// Only the fill style is modelled; borders, lengths, axis or negative colours are lost.
|
||||
skipped |= db.attrs.iter().any(|(k, _)| k != "gradient") || db.children.iter().any(|k| k.name != "cfvo");
|
||||
}
|
||||
_ => skipped = true,
|
||||
}
|
||||
}
|
||||
if skipped {
|
||||
cx.warn("some Excel 2010 conditional format extensions were skipped");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -576,9 +576,33 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
|||
}
|
||||
let _ = write!(s, "<tableParts count=\"{}\">{parts}</tableParts>", tables.len());
|
||||
}
|
||||
// Sparklines (Excel 2010 extension).
|
||||
// Excel 2010 extensions: solid data bars, then sparklines.
|
||||
let solid_bars: Vec<(&CondFormat, u32)> = sheet
|
||||
.cond_formats
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. }))
|
||||
.map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1)))
|
||||
.collect();
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("<extLst>");
|
||||
}
|
||||
if !solid_bars.is_empty() {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<ext uri=\"{{78C0D931-6437-407d-A8A1-0F90B4350D7D}}\" xmlns:x14=\"{X14_NS}\" xmlns:xm=\"{XM_NS}\"><x14:conditionalFormattings>"
|
||||
);
|
||||
for (cf, p) in &solid_bars {
|
||||
if let CfRule::DataBar { min, max, .. } = &cf.rule {
|
||||
let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max));
|
||||
let _ = write!(s, "<x14:conditionalFormatting><x14:cfRule type=\"dataBar\" id=\"{id}\"><x14:dataBar gradient=\"0\">{lo}{hi}");
|
||||
let _ = write!(s, "</x14:dataBar></x14:cfRule><xm:sqref>{}</xm:sqref></x14:conditionalFormatting>", sqref(&cf.ranges));
|
||||
}
|
||||
}
|
||||
s.push_str("</x14:conditionalFormattings></ext>");
|
||||
}
|
||||
if !sheet.sparklines.is_empty() {
|
||||
s.push_str("<extLst><ext uri=\"{05C60535-1F16-4fd2-B633-F4F36F0B64E0}\" xmlns:x14=\"http://schemas.microsoft.com/office/spreadsheetml/2009/9/main\"><x14:sparklineGroups xmlns:xm=\"http://schemas.microsoft.com/office/excel/2006/main\">");
|
||||
let _ = write!(s, "<ext uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\" xmlns:x14=\"{X14_NS}\"><x14:sparklineGroups xmlns:xm=\"{XM_NS}\">");
|
||||
for sp in &sheet.sparklines {
|
||||
let ty = match sp.kind {
|
||||
SparklineKind::Line => "",
|
||||
|
|
@ -596,7 +620,10 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
|
|||
sp.cell.a1()
|
||||
);
|
||||
}
|
||||
s.push_str("</x14:sparklineGroups></ext></extLst>");
|
||||
s.push_str("</x14:sparklineGroups></ext>");
|
||||
}
|
||||
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
|
||||
s.push_str("</extLst>");
|
||||
}
|
||||
s.push_str("</worksheet>");
|
||||
(s, rels)
|
||||
|
|
@ -717,6 +744,33 @@ fn quote(t: &str) -> String {
|
|||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
|
||||
const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main";
|
||||
|
||||
/// The id linking a solid data bar's `cfRule` to its x14 extension; unique per sheet because priorities are.
|
||||
fn solid_bar_id(priority: u32) -> String {
|
||||
format!("{{00000000-0000-0000-0000-{priority:012}}}")
|
||||
}
|
||||
|
||||
/// The `cfRule` extension that points a solid data bar at its x14 rule.
|
||||
fn solid_bar_ext(priority: u32) -> String {
|
||||
let id = solid_bar_id(priority);
|
||||
format!("<extLst><ext uri=\"{{B025F937-C7B1-47D3-B67F-A62EFF666E3E}}\" xmlns:x14=\"{X14_NS}\"><x14:id>{id}</x14:id></ext></extLst>")
|
||||
}
|
||||
|
||||
/// An x14 `cfvo`: unlike the 2006 one it has no `val` attribute, the value is an `xm:f` child.
|
||||
fn x14_cfvo(v: &CfValueKind) -> String {
|
||||
let (ty, val) = match v {
|
||||
CfValueKind::Min => return "<x14:cfvo type=\"min\"/>".into(),
|
||||
CfValueKind::Max => return "<x14:cfvo type=\"max\"/>".into(),
|
||||
CfValueKind::Number(n) => ("num", num(*n)),
|
||||
CfValueKind::Percent(n) => ("percent", num(*n)),
|
||||
CfValueKind::Percentile(n) => ("percentile", num(*n)),
|
||||
CfValueKind::Formula(f) => ("formula", text_to_file(f)),
|
||||
};
|
||||
format!("<x14:cfvo type=\"{ty}\"><xm:f>{}</xm:f></x14:cfvo>", esc(&val))
|
||||
}
|
||||
|
||||
fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String {
|
||||
let tl = cf.ranges.first().map(|r| r.start.a1()).unwrap_or_else(|| "A1".into());
|
||||
let stop = if cf.stop_if_true { " stopIfTrue=\"1\"" } else { "" };
|
||||
|
|
@ -812,12 +866,13 @@ fn cf_rule_xml(cf: &CondFormat, priority: u32, out: &mut Out) -> String {
|
|||
b.push_str("</colorScale>");
|
||||
format!("<cfRule type=\"colorScale\" priority=\"{priority}\"{stop}>{b}</cfRule>")
|
||||
}
|
||||
CfRule::DataBar { min, max, color, show_value, .. } => format!(
|
||||
"<cfRule type=\"dataBar\" priority=\"{priority}\"{stop}><dataBar{}>{}{}<color {}/></dataBar></cfRule>",
|
||||
CfRule::DataBar { min, max, color, show_value, gradient } => format!(
|
||||
"<cfRule type=\"dataBar\" priority=\"{priority}\"{stop}><dataBar{}>{}{}<color {}/></dataBar>{}</cfRule>",
|
||||
if *show_value { "" } else { " showValue=\"0\"" },
|
||||
cfvo_xml(min),
|
||||
cfvo_xml(max),
|
||||
if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) }
|
||||
if *color == Color::Auto { "rgb=\"FF638EC6\"".to_string() } else { color_attrs(color) },
|
||||
if *gradient { String::new() } else { solid_bar_ext(priority) }
|
||||
),
|
||||
CfRule::IconSet { set, thresholds, reverse, show_value } => {
|
||||
let mut b = format!("<iconSet iconSet=\"{}\"", esc_attr(set));
|
||||
|
|
|
|||
|
|
@ -396,7 +396,8 @@ pub fn color_attrs(c: &Color) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn font_xml(f: &Font, dxf: bool) -> String {
|
||||
/// `minor`: the font follows the theme's body font (`<scheme val="minor"/>`).
|
||||
fn font_xml(f: &Font, dxf: bool, minor: bool) -> String {
|
||||
let mut s = String::from("<font>");
|
||||
if f.bold {
|
||||
s.push_str("<b/>");
|
||||
|
|
@ -429,6 +430,9 @@ fn font_xml(f: &Font, dxf: bool) -> String {
|
|||
}
|
||||
if !dxf {
|
||||
let _ = write!(s, "<name val=\"{}\"/><family val=\"2\"/>", esc_attr(&f.name));
|
||||
if minor {
|
||||
s.push_str("<scheme val=\"minor\"/>");
|
||||
}
|
||||
}
|
||||
s.push_str("</font>");
|
||||
s
|
||||
|
|
@ -667,7 +671,7 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
|
|||
|| d.font.color != Color::Auto
|
||||
|| d.font.vert != VertAlign::Baseline
|
||||
{
|
||||
dx.push_str(&font_xml(&d.font, true));
|
||||
dx.push_str(&font_xml(&d.font, true, false));
|
||||
}
|
||||
if d.num_fmt != def.num_fmt {
|
||||
let id = nf.id(d.num_fmt.as_str());
|
||||
|
|
@ -699,8 +703,9 @@ pub fn write_styles(wb: &Workbook, dxfs: &[Style], extra_fmts: &[String]) -> (St
|
|||
s.push_str("</numFmts>");
|
||||
}
|
||||
let _ = write!(s, "<fonts count=\"{}\">", fonts.len());
|
||||
for f in &fonts {
|
||||
s.push_str(&font_xml(f, false));
|
||||
for (i, f) in fonts.iter().enumerate() {
|
||||
// A Normal font in the theme's body face follows the theme, as Excel writes it (#805).
|
||||
s.push_str(&font_xml(f, false, i == 0 && f.name == wb.theme.minor_font));
|
||||
}
|
||||
let _ = write!(s, "</fonts><fills count=\"{}\">", fills.len());
|
||||
for f in &fills {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use gridcraft_core::{CellRef, DateSystem, Value};
|
|||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, read_csv, sniff, write_csv};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
|
|
@ -109,11 +109,11 @@ fn writing() {
|
|||
Value::Error(e) => e.as_str().into(),
|
||||
Value::Array(_) => String::new(),
|
||||
};
|
||||
let out = String::from_utf8(write_csv(sh, b',', raw)).unwrap();
|
||||
let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap();
|
||||
assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n");
|
||||
let semi = String::from_utf8(write_csv(sh, b';', raw)).unwrap();
|
||||
let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap();
|
||||
assert!(semi.starts_with("a,b;"));
|
||||
assert!(write_csv(&Sheet::new("E"), b',', raw).is_empty());
|
||||
assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty());
|
||||
// Round trip.
|
||||
let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap();
|
||||
assert_eq!(v(&back, 0, 0), Value::text("a,b"));
|
||||
|
|
@ -142,3 +142,51 @@ fn hostile_csv() {
|
|||
let wb = read_csv(format!("{wide}x").as_bytes(), &CsvOptions::default()).unwrap();
|
||||
assert!(wb.sheet(0).unwrap().cells.is_empty(), "fields past column XFD are dropped");
|
||||
}
|
||||
|
||||
/// A sheet with `a` in A1 and `b` in `corner`.
|
||||
fn two_cells(corner: (u32, u32)) -> Sheet {
|
||||
let mut s = Sheet::new("S");
|
||||
s.set_value(CellRef::new(0, 0), Value::text("a"));
|
||||
s.set_value(CellRef::new(corner.0, corner.1), Value::text("b"));
|
||||
s
|
||||
}
|
||||
|
||||
fn text_of(sh: &Sheet) -> impl FnMut(CellRef) -> String + '_ {
|
||||
|c| match sh.value(c) {
|
||||
Value::Text(t) => t.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_far_apart_cells_are_refused_before_anything_is_allocated() {
|
||||
// A1 and XFD1048576 span the whole grid: 16 GiB of commas. Refused at once, with the numbers.
|
||||
let s = two_cells((gridcraft_core::MAX_ROWS - 1, gridcraft_core::MAX_COLS - 1));
|
||||
let err = write_csv(&s, b',', text_of(&s)).unwrap_err();
|
||||
assert!(matches!(err, IoError::TooLarge(_)), "{err}");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("1048576 rows by 16384 columns") && msg.contains("512 MiB"), "{msg}");
|
||||
// A single row or column across the grid is refused too when it is over the cap.
|
||||
let wide = two_cells((gridcraft_core::MAX_ROWS - 1, 0));
|
||||
assert!(write_csv_limited(&wide, b',', 1 << 20, text_of(&wide)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_position() {
|
||||
// 3 rows x 3 columns, 14 bytes: accepted at the cap, refused one byte under.
|
||||
let s = two_cells((2, 2));
|
||||
let ok = write_csv_limited(&s, b',', 14, text_of(&s)).unwrap();
|
||||
assert_eq!(String::from_utf8(ok).unwrap(), "a,,\r\n,,\r\n,,b\r\n");
|
||||
assert!(write_csv_limited(&s, b',', 13, text_of(&s)).is_err());
|
||||
// Rows that pass the lower bound but hold long text stop as soon as the cap is passed.
|
||||
let mut long = Sheet::new("L");
|
||||
for r in 0..50u32 {
|
||||
long.set_value(CellRef::new(r, 0), Value::text("x".repeat(100)));
|
||||
}
|
||||
let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err();
|
||||
assert!(err.to_string().contains("stopped at row"), "{err}");
|
||||
assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok());
|
||||
// A tab is one byte, like a comma.
|
||||
let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap();
|
||||
assert_eq!(tabs.len(), 14);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
//! Ink uses visible DrawingML geometry, and only representable polylines become editable ink.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_model::{Anchor, AnchorMode, Color, Shape, ShapeKind, Sheet, Workbook};
|
||||
|
||||
use super::minimal;
|
||||
use crate::xml::{self, El};
|
||||
use crate::{read_xlsx, write_xlsx};
|
||||
|
||||
const POINTS: &str = r#"<a:moveTo><a:pt x="-20" y="10"/></a:moveTo><a:lnTo><a:pt x="100" y="80"/></a:lnTo><a:lnTo><a:pt x="180" y="20"/></a:lnTo>"#;
|
||||
const PATH: &str = r#"<a:path w="200" h="100" fill="none">"#;
|
||||
|
||||
fn drawing() -> String {
|
||||
format!(
|
||||
r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
|
||||
<xdr:oneCellAnchor><xdr:from><xdr:col>0</xdr:col><xdr:colOff>190500</xdr:colOff><xdr:row>0</xdr:row><xdr:rowOff>285750</xdr:rowOff></xdr:from><xdr:ext cx="952500" cy="476250"/>
|
||||
<xdr:sp><xdr:nvSpPr><xdr:cNvPr id="1" name="Independent polyline"/><xdr:cNvSpPr/></xdr:nvSpPr><xdr:spPr>
|
||||
<a:xfrm><a:off x="0" y="0"/><a:ext cx="952500" cy="476250"/></a:xfrm>
|
||||
<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst>{PATH}{POINTS}</a:path></a:pathLst></a:custGeom>
|
||||
<a:noFill/><a:ln w="28575" cap="flat"><a:solidFill><a:srgbClr val="A12BCD"/></a:solidFill><a:miter/></a:ln>
|
||||
</xdr:spPr></xdr:sp><xdr:clientData/></xdr:oneCellAnchor></xdr:wsDr>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn read_drawing(drawing: &str) -> Workbook {
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>"#;
|
||||
let bytes = minimal(
|
||||
r#"<sheetData/><drawing r:id="rId1"/>"#,
|
||||
&[("xl/worksheets/_rels/sheet1.xml.rels", rels), ("xl/drawings/drawing1.xml", drawing)],
|
||||
"",
|
||||
"",
|
||||
);
|
||||
read_xlsx(&bytes).unwrap().0
|
||||
}
|
||||
|
||||
fn points(shape: &Shape) -> Vec<[f64; 2]> {
|
||||
shape
|
||||
.text
|
||||
.split(';')
|
||||
.map(|pair| {
|
||||
let (x, y) = pair.split_once(',').unwrap();
|
||||
[x.parse().unwrap(), y.parse().unwrap()]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_caption(element: &El) -> bool {
|
||||
(element.name == "t" && !element.text.is_empty()) || element.children.iter().any(has_caption)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() {
|
||||
let mut sheet = Sheet::new("Ink");
|
||||
for (id, width) in [(1, 3), (2, 12)] {
|
||||
sheet.shapes.push(Shape {
|
||||
id,
|
||||
kind: ShapeKind::Ink,
|
||||
anchor: Anchor { width: 100.0, height: 50.0, mode: AnchorMode::Absolute, ..Default::default() },
|
||||
fill: Color::Rgb(width << 16),
|
||||
line: Color::Rgb(0xA12BCD),
|
||||
text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(),
|
||||
format: Default::default(),
|
||||
});
|
||||
}
|
||||
let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() };
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut drawing = String::new();
|
||||
archive.by_name("xl/drawings/drawing1.xml").unwrap().read_to_string(&mut drawing).unwrap();
|
||||
let root = xml::parse(drawing.as_bytes()).unwrap();
|
||||
let shapes: Vec<_> = root.children.iter().filter_map(|anchor| anchor.child("sp")).collect();
|
||||
assert_eq!(shapes.len(), 2);
|
||||
for (shape, width) in shapes.iter().zip([3, 12]) {
|
||||
assert!(!has_caption(shape), "stroke coordinates must not be visible text");
|
||||
let properties = shape.child("spPr").unwrap();
|
||||
assert!(properties.child("prstGeom").is_none());
|
||||
assert!(properties.child("noFill").is_some());
|
||||
let paths: Vec<_> = properties.path(&["custGeom", "pathLst"]).unwrap().kids("path").collect();
|
||||
assert_eq!(paths.len(), 1);
|
||||
let path = paths[0];
|
||||
assert_eq!(path.attr("fill"), Some("none"));
|
||||
let w = path.attr_i64("w").unwrap();
|
||||
let h = path.attr_i64("h").unwrap();
|
||||
assert!(w > 0 && h > 0);
|
||||
assert_eq!(path.children.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["moveTo", "lnTo", "lnTo"]);
|
||||
for (command, [x, y]) in path.children.iter().zip([[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]) {
|
||||
let point = command.child("pt").unwrap();
|
||||
let actual_x = point.attr_i64("x").unwrap() as f64 * 100.0 / w as f64;
|
||||
let actual_y = point.attr_i64("y").unwrap() as f64 * 50.0 / h as f64;
|
||||
assert!((actual_x - x).abs() < 0.001 && (actual_y - y).abs() < 0.001);
|
||||
}
|
||||
let line = properties.child("ln").unwrap();
|
||||
assert_eq!(line.attr_i64("w"), Some(9525 * width));
|
||||
assert_eq!(line.attr("cap"), Some("flat"));
|
||||
assert!(line.child("miter").is_some());
|
||||
let color = line.path(&["solidFill", "srgbClr"]).unwrap();
|
||||
assert_eq!(color.attr("val"), Some("A12BCD"));
|
||||
if width > 8 {
|
||||
assert_eq!(color.child_val("alpha"), Some("45000"));
|
||||
} else {
|
||||
assert!(color.child("alpha").is_none_or(|a| a.attr("val") == Some("100000")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_polyline_from_an_independent_package_is_scaled_into_editable_ink() {
|
||||
for (width, alpha) in [(3, ""), (12, r#"<a:alpha val="45000"/>"#)] {
|
||||
let xml = drawing()
|
||||
.replace("w=\"28575\"", &format!("w=\"{}\"", width * 9525))
|
||||
.replace(r#"<a:srgbClr val="A12BCD"/>"#, &format!(r#"<a:srgbClr val="A12BCD">{alpha}</a:srgbClr>"#));
|
||||
let wb = read_drawing(&xml);
|
||||
let shapes = &wb.sheet(0).unwrap().shapes;
|
||||
assert_eq!(shapes.len(), 1);
|
||||
let shape = &shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Ink);
|
||||
assert_eq!(points(shape), [[-10.0, 5.0], [50.0, 40.0], [90.0, 10.0]]);
|
||||
assert_eq!(shape.line, Color::Rgb(0xA12BCD));
|
||||
assert_eq!(shape.fill, Color::Rgb(width << 16));
|
||||
assert_eq!((shape.anchor.dx, shape.anchor.dy), (20.0, 30.0));
|
||||
assert_eq!((shape.anchor.width, shape.anchor.height), (100.0, 50.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_points_are_not_snapped_to_native_tenth_units() {
|
||||
let xml = drawing().replace("w=\"200\"", "w=\"2000000\"").replace(
|
||||
POINTS,
|
||||
r#"<a:moveTo><a:pt x="2001" y="10"/></a:moveTo><a:lnTo><a:pt x="1000000" y="80"/></a:lnTo><a:lnTo><a:pt x="1800000" y="20"/></a:lnTo>"#,
|
||||
);
|
||||
let wb = read_drawing(&xml);
|
||||
let shape = &wb.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Ink);
|
||||
let original = points(shape);
|
||||
assert!((original[0][0] - 0.10005).abs() < 0.00000001, "an imported coordinate is not necessarily a native tenth-unit point");
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
let after = &reopened.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(after.kind, ShapeKind::Ink);
|
||||
for (actual, expected) in points(after).iter().zip(original) {
|
||||
for axis in 0..2 {
|
||||
// A saved DrawingML coordinate is quantized to one EMU, plus f32 model precision.
|
||||
assert!((actual[axis] - expected[axis]).abs() < 0.00006);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_geometry_or_appearance_is_not_reclassified_as_editable_ink() {
|
||||
let original = drawing();
|
||||
let cases = [
|
||||
("formula coordinate", original.replace("x=\"-20\"", "x=\"wd2\"")),
|
||||
("nonfinite coordinate", original.replace("x=\"-20\"", "x=\"NaN\"")),
|
||||
("overflow coordinate", original.replace("x=\"-20\"", "x=\"9223372036854775808\"")),
|
||||
("zero path width", original.replace("w=\"200\"", "w=\"0\"")),
|
||||
("missing point", original.replace(r#"<a:pt x="100" y="80"/>"#, "")),
|
||||
(
|
||||
"curve",
|
||||
original
|
||||
.replace(r#"<a:lnTo><a:pt x="100" y="80"/></a:lnTo>"#, r#"<a:quadBezTo><a:pt x="40" y="20"/><a:pt x="100" y="80"/></a:quadBezTo>"#),
|
||||
),
|
||||
("closed path", original.replace("</a:path>", "<a:close/></a:path>")),
|
||||
("multiple paths", original.replace("</a:pathLst>", &format!("{PATH}{POINTS}</a:path></a:pathLst>"))),
|
||||
("filled path", original.replace("fill=\"none\"", "fill=\"norm\"")),
|
||||
("filled shape", original.replace("<a:noFill/>", "<a:solidFill><a:srgbClr val=\"112233\"/></a:solidFill>")),
|
||||
("rotation", original.replace("<a:xfrm>", "<a:xfrm rot=\"60000\">")),
|
||||
("reflection", original.replace("<a:xfrm>", "<a:xfrm flipH=\"1\">")),
|
||||
("square caps", original.replace("cap=\"flat\"", "cap=\"sq\"")),
|
||||
("round join", original.replace("<a:miter/>", "<a:round/>")),
|
||||
("dash", original.replace("</a:ln>", "<a:prstDash val=\"dash\"/></a:ln>")),
|
||||
("arrow", original.replace("</a:ln>", "<a:headEnd type=\"triangle\"/></a:ln>")),
|
||||
(
|
||||
"caption",
|
||||
original.replace("</xdr:sp>", "<xdr:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:t>Caption</a:t></a:r></a:p></xdr:txBody></xdr:sp>"),
|
||||
),
|
||||
("fractional width", original.replace("w=\"28575\"", "w=\"33338\"")),
|
||||
("unsupported opacity", original.replace(r#"<a:srgbClr val="A12BCD"/>"#, r#"<a:srgbClr val="A12BCD"><a:alpha val="50000"/></a:srgbClr>"#)),
|
||||
];
|
||||
for (name, xml) in cases {
|
||||
let wb = read_drawing(&xml);
|
||||
assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink), "{name} was silently converted to editable ink");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_polyline_is_not_truncated_into_different_ink() {
|
||||
let mut commands = String::from(r#"<a:moveTo><a:pt x="0" y="0"/></a:moveTo>"#);
|
||||
for _ in 0..20_000 {
|
||||
commands.push_str(r#"<a:lnTo><a:pt x="100" y="80"/></a:lnTo>"#);
|
||||
}
|
||||
let wb = read_drawing(&drawing().replace(POINTS, &commands));
|
||||
assert!(wb.sheet(0).unwrap().shapes.iter().all(|s| s.kind != ShapeKind::Ink));
|
||||
}
|
||||
|
|
@ -194,6 +194,24 @@ fn hostile_protected_ranges_stay_locked() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_number_format_code_does_not_stop_the_open() {
|
||||
// A format code far over the 255-character cap: the file still opens, the format code is kept as
|
||||
// stored (display falls back to General) and the cell keeps its value.
|
||||
let code = format!("# ?/{}", "0".repeat(65_536));
|
||||
let styles = format!(
|
||||
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><numFmts count="1"><numFmt numFmtId="164" formatCode="{code}"/></numFmts>
|
||||
<cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="164"/></cellXfs></styleSheet>"#
|
||||
);
|
||||
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
|
||||
let body = r#"<sheetData><row r="1"><c r="A1" s="1"><v>0.5</v></c></row></sheetData>"#;
|
||||
let (wb, _) = read_xlsx(&minimal(body, &[("xl/styles.xml", &styles)], rels, "")).unwrap();
|
||||
let s = wb.sheet(0).unwrap();
|
||||
let cell = s.cell(gridcraft_core::CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert_eq!(s.value(gridcraft_core::CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(0.5));
|
||||
assert_eq!(wb.styles.get(cell.style).num_fmt.as_str(), code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlinks_past_the_cell_cap_are_reported() {
|
||||
// A styles part, so the only possible warning is the hyperlink one.
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ mod chart_bool_default;
|
|||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
mod fixtures;
|
||||
mod ink_roundtrip;
|
||||
mod literal_category_roundtrip;
|
||||
mod malformed;
|
||||
mod ods_tests;
|
||||
|
|
|
|||
|
|
@ -484,6 +484,234 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() {
|
|||
assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some())));
|
||||
}
|
||||
|
||||
/// Where a link with `target`, in a workbook read from `from`, points once saved into `to`.
|
||||
#[cfg(unix)]
|
||||
fn rebased(target: &str, from: &str, to: &str) -> String {
|
||||
let link = |rel_type: &str| gridcraft_model::PreservedRel {
|
||||
id: "rId1".into(),
|
||||
rel_type: format!("{NS_R}/{rel_type}"),
|
||||
target: target.into(),
|
||||
external: true,
|
||||
};
|
||||
let part = gridcraft_model::PreservedPart {
|
||||
name: "xl/externalLinks/externalLink1.xml".into(),
|
||||
content_type: String::new(),
|
||||
data: Arc::from(&b""[..]),
|
||||
rels: vec![link("externalLinkPath"), link("hyperlink")],
|
||||
};
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
wb.preserved = Some(Arc::new(gridcraft_model::Preserved { parts: vec![part], ..Default::default() }));
|
||||
wb.source_dir = Some(from.into());
|
||||
let Some(out) = crate::rebase_links(&wb, std::path::Path::new(to)) else { return target.into() };
|
||||
let rels = &out.preserved.as_ref().unwrap().parts[0].rels;
|
||||
assert_eq!(rels[1].target, target, "only paths of linked workbooks change");
|
||||
rels[0].target.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn saving_into_another_folder_rebases_relative_links_to_workbooks() {
|
||||
assert_eq!(rebased("Source.xlsx", "/links", "/links/move2"), "../Source.xlsx");
|
||||
assert_eq!(rebased("Source.xlsx", "/links", "/other/deeper"), "../../links/Source.xlsx");
|
||||
assert_eq!(rebased("../Source.xlsx", "/links/move2", "/links"), "Source.xlsx");
|
||||
assert_eq!(rebased("sub\\Source.xlsx", "/links", "/links/sub"), "Source.xlsx");
|
||||
assert_eq!(rebased("./sub/../Source.xlsx", "/links", "/links/sub/"), "../Source.xlsx");
|
||||
// Folder names from the file system are written as a URI path; the target's own spelling stays.
|
||||
assert_eq!(rebased("My%20Book.xlsx", "/links/q2 data", "/links"), "q2%20data/My%20Book.xlsx");
|
||||
assert_eq!(rebased("Résumé.xlsx", "/a/Été", "/a"), "Été/Résumé.xlsx");
|
||||
// The same folder, absolute paths, URLs and paths above the root are kept as they are.
|
||||
assert_eq!(rebased("Source.xlsx", "/links/", "/links/x/.."), "Source.xlsx");
|
||||
for target in ["/links/Source.xlsx", "file:///C:/links/Source.xlsx", "C:\\links\\Source.xlsx", "https://example.com/Source.xlsx", "../../x.xlsx"]
|
||||
{
|
||||
assert_eq!(rebased(target, "/links", "/links/move2"), target);
|
||||
}
|
||||
// Without the folder it was read from, nothing to rebase.
|
||||
let (mut wb, _) = read_xlsx(&rich_package()).unwrap();
|
||||
assert!(crate::rebase_links(&wb, std::path::Path::new("/elsewhere")).is_none());
|
||||
wb.source_dir = Some("relative/dir".into());
|
||||
assert!(crate::rebase_links(&wb, std::path::Path::new("/elsewhere")).is_none());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ chart sheets (#727)
|
||||
|
||||
const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
|
||||
const CT_PREFIX: &str = "application/vnd.openxmlformats-officedocument";
|
||||
|
||||
fn chart_xml(formula: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><c:chartSpace xmlns:c="{NS_C}"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numRef><c:f>{formula}</c:f></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn chartsheet_xml(note: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><chartsheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetViews><sheetView workbookViewId="0"/></sheetViews><pageSetup r:id="rId2"/><drawing r:id="rId1"/><!-- {note} --></chartsheet>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn drawing_xml(note: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><!-- {note} --></xdr:wsDr>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn worksheet_xml(value: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>{value}</t></is></c></row></sheetData></worksheet>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Sheets in file order: Data, Chart1 (a chart sheet on Data), More, Spare, Last (a hidden chart
|
||||
/// sheet on More, last in the list). "More" has a print area; the active tab is "More".
|
||||
fn chartsheet_package() -> Vec<u8> {
|
||||
let workbook = format!(
|
||||
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><bookViews><workbookView activeTab="2"/></bookViews><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Chart1" sheetId="2" r:id="rId2"/><sheet name="More" sheetId="3" r:id="rId3"/><sheet name="Spare" sheetId="4" r:id="rId4"/><sheet name="Last" sheetId="9" state="hidden" r:id="rId5"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="2">More!$A$1:$B$2</definedName></definedNames></workbook>"#
|
||||
);
|
||||
let content_types = format!(
|
||||
r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="bin" ContentType="{CT_PREFIX}.spreadsheetml.printerSettings"/><Override PartName="/xl/workbook.xml" ContentType="{CT_PREFIX}.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet3.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="{CT_PREFIX}.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/chartsheets/sheet2.xml" ContentType="{CT_PREFIX}.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="{CT_PREFIX}.drawing+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="{CT_PREFIX}.drawing+xml"/><Override PartName="/xl/charts/chart1.xml" ContentType="{CT_PREFIX}.drawingml.chart+xml"/><Override PartName="/xl/charts/chart2.xml" ContentType="{CT_PREFIX}.drawingml.chart+xml"/></Types>"#
|
||||
);
|
||||
let (cs1, cs2) = (chartsheet_xml("first"), chartsheet_xml("last"));
|
||||
let (d1, d2) = (drawing_xml("first"), drawing_xml("last"));
|
||||
let (c1, c2) = (chart_xml("Data!$A$1:$A$3"), chart_xml("'More'!$A$1"));
|
||||
let (s1, s2, s3) = (worksheet_xml("data"), worksheet_xml("more"), worksheet_xml("spare"));
|
||||
make_zip(&[
|
||||
("[Content_Types].xml", content_types.as_bytes()),
|
||||
("_rels/.rels", rels(&[("rId1", "officeDocument", "xl/workbook.xml")]).as_bytes()),
|
||||
("xl/workbook.xml", workbook.as_bytes()),
|
||||
(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
rels(&[
|
||||
("rId1", "worksheet", "worksheets/sheet1.xml"),
|
||||
("rId2", "chartsheet", "chartsheets/sheet1.xml"),
|
||||
("rId3", "worksheet", "worksheets/sheet2.xml"),
|
||||
("rId4", "worksheet", "worksheets/sheet3.xml"),
|
||||
("rId5", "chartsheet", "chartsheets/sheet2.xml"),
|
||||
])
|
||||
.as_bytes(),
|
||||
),
|
||||
("xl/worksheets/sheet1.xml", s1.as_bytes()),
|
||||
("xl/worksheets/sheet2.xml", s2.as_bytes()),
|
||||
("xl/worksheets/sheet3.xml", s3.as_bytes()),
|
||||
("xl/chartsheets/sheet1.xml", cs1.as_bytes()),
|
||||
("xl/chartsheets/sheet2.xml", cs2.as_bytes()),
|
||||
(
|
||||
"xl/chartsheets/_rels/sheet1.xml.rels",
|
||||
rels(&[("rId1", "drawing", "../drawings/drawing1.xml"), ("rId2", "printerSettings", "../printerSettings/printerSettings1.bin")])
|
||||
.as_bytes(),
|
||||
),
|
||||
("xl/chartsheets/_rels/sheet2.xml.rels", rels(&[("rId1", "drawing", "../drawings/drawing2.xml")]).as_bytes()),
|
||||
("xl/printerSettings/printerSettings1.bin", PRINTER),
|
||||
("xl/drawings/drawing1.xml", d1.as_bytes()),
|
||||
("xl/drawings/drawing2.xml", d2.as_bytes()),
|
||||
("xl/drawings/_rels/drawing1.xml.rels", rels(&[("rId1", "chart", "../charts/chart1.xml")]).as_bytes()),
|
||||
("xl/drawings/_rels/drawing2.xml.rels", rels(&[("rId1", "chart", "../charts/chart2.xml")]).as_bytes()),
|
||||
("xl/charts/chart1.xml", c1.as_bytes()),
|
||||
("xl/charts/chart2.xml", c2.as_bytes()),
|
||||
])
|
||||
}
|
||||
|
||||
/// The sheets of a written workbook: (name, state) in file order.
|
||||
fn written_sheets(u: &Unpacked) -> Vec<(String, String)> {
|
||||
let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
wb.child("sheets").unwrap().kids("sheet").map(|s| (s.attr("name").unwrap().to_string(), s.attr("state").unwrap_or("").to_string())).collect()
|
||||
}
|
||||
|
||||
/// The part a written chart sheet is in, found through the workbook relationship; its drawing and
|
||||
/// that drawing's chart, followed through relationships.
|
||||
fn chart_sheet_chain(u: &Unpacked, name: &str) -> (String, String, String) {
|
||||
let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
let id = wb.child("sheets").unwrap().kids("sheet").find(|s| s.attr("name") == Some(name)).unwrap().attr("id").unwrap().to_string();
|
||||
let (ty, part) = u.target("xl/workbook.xml", &id);
|
||||
assert!(ty.ends_with("/chartsheet"), "{ty}");
|
||||
let drawing = u.rels(&part).into_iter().find(|r| r.1.ends_with("/drawing")).unwrap().2;
|
||||
let chart = u.rels(&drawing).into_iter().find(|r| r.1.ends_with("/chart")).unwrap().2;
|
||||
(part, drawing, chart)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_sheets_survive_a_round_trip_in_place() {
|
||||
let (wb, rep) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep.warnings);
|
||||
let names: Vec<&str> = wb.sheets.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Data", "More", "Spare"], "chart sheets are not worksheets");
|
||||
|
||||
let out = write_xlsx(&wb).unwrap();
|
||||
let u = Unpacked::new(&out);
|
||||
u.assert_valid();
|
||||
// Same order, same visibility, the way Excel shows the tabs.
|
||||
assert_eq!(
|
||||
written_sheets(&u),
|
||||
[("Data", ""), ("Chart1", ""), ("More", ""), ("Spare", ""), ("Last", "hidden")].map(|(a, b)| (a.to_string(), b.to_string())),
|
||||
);
|
||||
// Sheet ids are unique.
|
||||
let wbx = u.text("xl/workbook.xml");
|
||||
let mut ids: Vec<String> = wbx.match_indices("<sheet ").map(|(i, _)| attr_of(&wbx[i..], "sheet", "sheetId")).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 5, "{wbx}");
|
||||
// Positions in the file, not in the model, for the active tab and the print area.
|
||||
assert_eq!(attr_of(&wbx, "workbookView", "activeTab"), "2", "More is the third tab");
|
||||
assert_eq!(attr_of(&wbx, "definedName", "localSheetId"), "2", "{wbx}");
|
||||
|
||||
// Every part of both chart sheets is in the output, byte for byte, behind the same chain.
|
||||
for (name, cs, drawing, chart) in [("Chart1", "first", "first", "Data!$A$1:$A$3"), ("Last", "last", "last", "'More'!$A$1")] {
|
||||
let (cs_part, d_part, c_part) = chart_sheet_chain(&u, name);
|
||||
assert_eq!(u.text(&cs_part), chartsheet_xml(cs), "{name}");
|
||||
assert_eq!(u.text(&d_part), drawing_xml(drawing), "{name}");
|
||||
assert_eq!(u.text(&c_part), chart_xml(chart), "{name}");
|
||||
}
|
||||
// The first chart sheet's printer settings follow its `<pageSetup r:id>`.
|
||||
let (cs_part, _, _) = chart_sheet_chain(&u, "Chart1");
|
||||
let ps = u.target(&cs_part, &attr_of(&u.text(&cs_part), "pageSetup", "r:id"));
|
||||
assert!(ps.0.ends_with("/printerSettings"));
|
||||
assert_eq!(u.parts.get(&ps.1).unwrap(), PRINTER);
|
||||
|
||||
// Reading the output again finds the same two chart sheets, and a second save is stable.
|
||||
let (wb2, rep2) = read_xlsx(&out).unwrap();
|
||||
assert!(!rep2.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep2.warnings);
|
||||
let again = Unpacked::new(&write_xlsx(&wb2).unwrap());
|
||||
again.assert_valid();
|
||||
assert_eq!(written_sheets(&again), written_sheets(&u));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_sheet_whose_data_sheet_was_renamed_is_dropped_with_its_parts() {
|
||||
let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
wb.sheet_mut(0).unwrap().name = "Renamed".into();
|
||||
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
|
||||
u.assert_valid();
|
||||
// "Chart1" plots Data, which is gone under that name; "Last" plots More, which is not.
|
||||
assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["Renamed", "More", "Spare", "Last"]);
|
||||
assert!(u.find(chart_xml("Data!$A$1:$A$3").as_bytes()).is_empty());
|
||||
assert!(u.find(drawing_xml("first").as_bytes()).is_empty());
|
||||
assert!(u.find(PRINTER).is_empty());
|
||||
chart_sheet_chain(&u, "Last");
|
||||
let wbx = u.text("xl/workbook.xml");
|
||||
assert_eq!(attr_of(&wbx, "workbookView", "activeTab"), "1");
|
||||
assert_eq!(attr_of(&wbx, "definedName", "localSheetId"), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_sheet_whose_name_a_worksheet_took_is_dropped() {
|
||||
let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
wb.sheet_mut(2).unwrap().name = "LAST".into();
|
||||
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
|
||||
u.assert_valid();
|
||||
assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["Data", "Chart1", "More", "LAST"]);
|
||||
assert!(u.find(chart_xml("'More'!$A$1").as_bytes()).is_empty());
|
||||
chart_sheet_chain(&u, "Chart1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_name_the_sheets_a_chart_plots() {
|
||||
use crate::passthrough::sheet_names_in;
|
||||
assert_eq!(sheet_names_in("Data!$A$1:$A$3"), ["Data"]);
|
||||
assert_eq!(sheet_names_in("'My ''Sheet'!$A$1,Data!$B$1"), ["My 'Sheet", "Data"]);
|
||||
assert_eq!(sheet_names_in("SUM(Data!A1:A3,'Other sheet'!B2)"), ["Data", "Other sheet"]);
|
||||
assert!(sheet_names_in("[1]Ext!A1").is_empty() && sheet_names_in("[1]Ext!A1:A3").is_empty());
|
||||
assert!(sheet_names_in("Name").is_empty() && sheet_names_in("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_version_and_calc_features_survive_a_save() {
|
||||
// Excel trusts the saved cell values only when workbook.xml carries both <fileVersion> and the
|
||||
|
|
|
|||
|
|
@ -1051,6 +1051,76 @@ fn package_kind_sets_workbook_content_type() {
|
|||
assert_eq!(crate::XlsxKind::from_path("noext"), crate::XlsxKind::Workbook);
|
||||
}
|
||||
|
||||
/// The workbook's default font (`fonts[0]`, the Normal style's) is what every default-formatted
|
||||
/// cell shows, typed or empty; saving keeps it instead of resetting it to Calibri 11 (#805).
|
||||
#[test]
|
||||
fn default_font_survives_a_round_trip() {
|
||||
let styles = |font0: &str| {
|
||||
format!(
|
||||
r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font>{font0}</font><font><b/><sz val="14"/><color theme="1"/><name val="Arial"/><family val="2"/></font></fonts>
|
||||
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills><borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
|
||||
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
|
||||
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
|
||||
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>"#
|
||||
)
|
||||
};
|
||||
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
|
||||
let body = r#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>typed text</t></is></c><c r="B1" s="1"><v>1</v></c></row></sheetData>"#;
|
||||
let font0 = |bytes: &[u8]| {
|
||||
let mut pkg = Package::open(bytes).unwrap();
|
||||
let st = pkg.read_xml("xl/styles.xml").unwrap().unwrap();
|
||||
let f = st.child("fonts").unwrap().kids("font").next().unwrap().clone();
|
||||
let val = |n: &str| f.child(n).and_then(|e| e.attr("val")).map(str::to_string);
|
||||
(val("sz"), val("name"), val("family"), val("scheme"))
|
||||
};
|
||||
|
||||
let file =
|
||||
super::minimal(body, &[("xl/styles.xml", &styles(r#"<sz val="10"/><color theme="1"/><name val="Arial"/><family val="2"/>"#))], rels, "");
|
||||
let (wb, _) = read_xlsx(&file).unwrap();
|
||||
let saved = write_xlsx(&wb).unwrap();
|
||||
assert_eq!(font0(&saved), (Some("10".into()), Some("Arial".into()), Some("2".into()), None));
|
||||
let (back, _) = read_xlsx(&saved).unwrap();
|
||||
let normal = &back.styles.get(StyleId::DEFAULT).font;
|
||||
assert_eq!((normal.name.as_str(), normal.size), ("Arial", 10.0), "empty cells and new input take the Normal font");
|
||||
let s = back.sheet(0).unwrap();
|
||||
assert_eq!(s.cell(at("A1")).unwrap().style, StyleId::DEFAULT);
|
||||
let b1 = &back.styles.get(s.cell(at("B1")).unwrap().style).font;
|
||||
assert!(b1.bold && b1.size == 14.0, "an explicit font is still its own: {b1:?}");
|
||||
|
||||
// Excel's own Normal font follows the theme's body font; the scheme link survives too.
|
||||
let file = super::minimal(
|
||||
body,
|
||||
&[("xl/styles.xml", &styles(r#"<name val="Calibri"/><family val="2"/><color theme="1"/><sz val="12"/><scheme val="minor"/>"#))],
|
||||
rels,
|
||||
"",
|
||||
);
|
||||
let saved = write_xlsx(&read_xlsx(&file).unwrap().0).unwrap();
|
||||
assert_eq!(font0(&saved), (Some("12".into()), Some("Calibri".into()), Some("2".into()), Some("minor".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_data_bar_survives_with_sparklines_and_gradient_neighbours() {
|
||||
let mut wb = Workbook::new();
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.set_value(at("A1"), Value::Number(1.0));
|
||||
let bar =
|
||||
|gradient: bool, max: CfValueKind| CfRule::DataBar { min: CfValueKind::Min, max, color: Color::Rgb(0x638EC6), gradient, show_value: true };
|
||||
let rules = [(true, "A1", CfValueKind::Max), (false, "B1", CfValueKind::Formula("$C$1".into())), (false, "D1:D3", CfValueKind::Number(9.0))];
|
||||
for (i, (gradient, range, max)) in rules.into_iter().enumerate() {
|
||||
s.cond_formats.push(CondFormat { ranges: vec![rr(range)], rule: bar(gradient, max), priority: i as u32 + 1, stop_if_true: false });
|
||||
}
|
||||
s.sparklines.push(Sparkline { cell: at("F1"), source: "A1:C1".into(), kind: SparklineKind::Line, color: Color::Rgb(0x376092), markers: false });
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
// x14 cfvo values are `xm:f` children; a `val` attribute there is not valid x14.
|
||||
let (xml, _) = sheet1_xml(&bytes);
|
||||
assert!(xml.contains("<x14:cfvo type=\"num\"><xm:f>9</xm:f></x14:cfvo>"), "{xml}");
|
||||
let (back, rep) = read_xlsx(&bytes).unwrap();
|
||||
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
|
||||
let b = &back.sheets[0];
|
||||
assert_eq!(b.cond_formats, wb.sheets[0].cond_formats);
|
||||
assert_eq!(b.sparklines.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_props_keep_boundary_whitespace() {
|
||||
let mut wb = Workbook::new();
|
||||
|
|
|
|||
|
|
@ -330,11 +330,12 @@ fn reads_values_formulas_formats_and_layout() {
|
|||
// Shared formula: relative offsets resolve per cell.
|
||||
assert_eq!(ftext(&wb, "B5").as_deref(), Some("A5*10"));
|
||||
assert_eq!(ftext(&wb, "B6").as_deref(), Some("A6*10"));
|
||||
// Array formula: the anchor holds it, the other cell keeps its value.
|
||||
// Array formula: the anchor holds it; the other cell's cached result is dropped so that
|
||||
// the formula fills the range on recalculation (as when an .xlsx is read, #156).
|
||||
let c5 = wb.sheets[0].cell(CellRef::parse("C5").unwrap()).unwrap().formula.clone().unwrap();
|
||||
assert_eq!((c5.text.as_str(), c5.array), ("A5:A6*2", Some(RangeRef::parse("C5:C6").unwrap())));
|
||||
assert_eq!(ftext(&wb, "C6"), None);
|
||||
assert_eq!(v(&wb, 0, "C6"), Value::Number(4.0));
|
||||
assert_eq!(v(&wb, 0, "C6"), Value::Empty);
|
||||
// Number formats, merges, sizes, names, print area; embedded chart records ignored.
|
||||
let sh = &wb.sheets[0];
|
||||
assert_eq!(wb.styles.get(sh.style_id(CellRef::parse("A4").unwrap())).num_fmt.as_str(), "yyyy-mm-dd");
|
||||
|
|
@ -492,6 +493,72 @@ fn sheets_share_one_record_budget() {
|
|||
assert!(err.contains("same substream"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_using_a_skipped_name_keep_their_saved_results() {
|
||||
// Lbl "ExtRange" = [Source.xlsx]Sheet1!$A$1:$A$3 (PtgArea3d through an external SupBook),
|
||||
// "Local" = Sheet1!$A$1 and "Twice" = ExtRange*2. Only the first is skipped on import; a
|
||||
// name that uses it is still read as it was.
|
||||
let ext = cat(&[&[0x3B], &1u16.to_le_bytes(), &u16s(&[0, 2, 0, 0])]);
|
||||
let local = cat(&[&[0x3A], &0u16.to_le_bytes(), &u16s(&[0, 0])]);
|
||||
let twice = cat(&[&[0x23], &1u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
|
||||
let lbl = |name: &str, rgce: &[u8]| {
|
||||
rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce]))
|
||||
};
|
||||
let boundsheet = |pos: u32| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"]));
|
||||
let globals = |pos: u32| {
|
||||
cat(&[
|
||||
&bof(0x0005),
|
||||
&boundsheet(pos),
|
||||
&rec(0x01AE, &u16s(&[1, 0x0401])),
|
||||
&rec(0x01AE, &cat(&[&u16s(&[1]), &xlstr("Source.xlsx"), &xlstr("Sheet1")])),
|
||||
&rec(0x0017, &cat(&[&u16s(&[2]), &u16s(&[0, 0, 0]), &u16s(&[1, 0, 0])])),
|
||||
&lbl("ExtRange", &ext),
|
||||
&lbl("Local", &local),
|
||||
&lbl("Twice", &twice),
|
||||
&eof(),
|
||||
])
|
||||
};
|
||||
let sum_ext = cat(&[&[0x43], &1u32.to_le_bytes(), &[0x22, 1], &4u16.to_le_bytes()]);
|
||||
let local_x2 = cat(&[&[0x43], &2u32.to_le_bytes(), &ptg_int(2), &[0x05]]);
|
||||
let sheet = cat(&[
|
||||
&bof(0x0010),
|
||||
&rec(0x0203, &cat(&[&cell(0, 0, 0), &10.0f64.to_le_bytes()])),
|
||||
&formula(5, 1, num(60.0), &sum_ext),
|
||||
&formula(6, 1, num(20.0), &local_x2),
|
||||
&eof(),
|
||||
]);
|
||||
let pos = globals(0).len() as u32;
|
||||
let (wb, report) = read_xls(&compound("Workbook", &cat(&[&globals(pos), &sheet]))).unwrap();
|
||||
let names: Vec<_> = wb.names.iter().map(|n| (n.name.as_str(), n.formula.as_str())).collect();
|
||||
assert_eq!(names, [("Local", "Sheet1!$A$1"), ("Twice", "ExtRange*2")]);
|
||||
assert_eq!(ftext(&wb, "B6"), None);
|
||||
assert_eq!(v(&wb, 0, "B6"), Value::Number(60.0));
|
||||
assert_eq!(ftext(&wb, "B7").as_deref(), Some("Local*2"));
|
||||
assert!(report.warnings.iter().any(|w| w.contains("defined names") && w.contains("saved results")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_iterative_calculation_settings() {
|
||||
// CalcMode, CalcCount, CalcIter and CalcDelta repeat in every worksheet substream; here only
|
||||
// the first sheet carries them, so the sheet after it must not reset them to the defaults.
|
||||
// CalcPrecision sits in the globals.
|
||||
let calc = cat(&[&rec(0x000D, &u16s(&[0])), &rec(0x000C, &u16s(&[50])), &rec(0x0011, &u16s(&[1])), &rec(0x0010, &0.01f64.to_le_bytes())]);
|
||||
let sheet1 = cat(&[&bof(0x0010), &calc, &eof()]);
|
||||
let sheet2 = cat(&[&bof(0x0010), &eof()]);
|
||||
let boundsheet = |pos: u32, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, name.len() as u8, 0], name.as_bytes()]));
|
||||
let globals = |p1: u32, p2: u32| cat(&[&bof(0x0005), &rec(0x000E, &u16s(&[0])), &boundsheet(p1, "A"), &boundsheet(p2, "B"), &eof()]);
|
||||
let p1 = globals(0, 0).len() as u32;
|
||||
let p2 = p1 + sheet1.len() as u32;
|
||||
let (wb, _) = read_xls(&compound("Workbook", &cat(&[&globals(p1, p2), &sheet1, &sheet2]))).unwrap();
|
||||
assert!(wb.calc.iterative);
|
||||
assert_eq!((wb.calc.max_iterations, wb.calc.max_change), (50, 0.01));
|
||||
assert_eq!(wb.calc.mode, gridcraft_model::CalcMode::Manual);
|
||||
assert!(wb.calc.precision_as_displayed);
|
||||
// Without the records: Excel's defaults, iteration off.
|
||||
let (wb, _) = read_xls(&compound("Workbook", &sample(false))).unwrap();
|
||||
assert_eq!(wb.calc, gridcraft_model::CalcSettings::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_format_shorter_than_its_count_keeps_its_characters() {
|
||||
// A FORMAT whose cch is one more than the characters in the record (seen in real files):
|
||||
|
|
|
|||
|
|
@ -224,9 +224,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
};
|
||||
|
||||
let mut wb_rels = Rels::default();
|
||||
// Kept content first: the chart sheets among the worksheets need their relationship ids.
|
||||
let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out);
|
||||
let mut pivots = crate::pivot::PivotWriter::default();
|
||||
let mut sheets_xml = String::new();
|
||||
let mut sheet_id = 1usize;
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let (before, next) = kept.sheet_entries(i, false, sheet_id);
|
||||
sheets_xml.push_str(&before);
|
||||
sheet_id = next;
|
||||
let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out);
|
||||
pivots.add_sheet(wb, i, &mut out, &mut rels);
|
||||
let name = format!("xl/worksheets/sheet{}.xml", i + 1);
|
||||
|
|
@ -242,8 +248,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = sheet.name.chars().take(31).collect();
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{}\"{state} r:id=\"{rid}\"/>", esc_attr(&name), i + 1);
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{sheet_id}\"{state} r:id=\"{rid}\"/>", esc_attr(&name));
|
||||
sheet_id += 1;
|
||||
}
|
||||
sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, sheet_id).0);
|
||||
|
||||
// Defined names, then print areas and titles.
|
||||
let mut names = String::new();
|
||||
|
|
@ -259,7 +267,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
}
|
||||
let _ = write!(names, "<definedName name=\"{}\"", esc_attr(&n.name));
|
||||
if let Some(sc) = n.scope {
|
||||
let _ = write!(names, " localSheetId=\"{sc}\"");
|
||||
let _ = write!(names, " localSheetId=\"{}\"", kept.file_index(sc));
|
||||
}
|
||||
if n.hidden {
|
||||
names.push_str(" hidden=\"1\"");
|
||||
|
|
@ -280,7 +288,13 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let q = quote_sheet(&sheet.name);
|
||||
if let Some(pa) = &sheet.print.print_area {
|
||||
let _ = write!(names, "<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{i}\">{}!{}</definedName>", esc(&q), abs_range(pa));
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{}\">{}!{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&q),
|
||||
abs_range(pa)
|
||||
);
|
||||
}
|
||||
let mut titles = vec![];
|
||||
if let Some((c0, c1)) = sheet.print.title_cols {
|
||||
|
|
@ -290,7 +304,12 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1)))));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
let _ = write!(names, "<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{i}\">{}</definedName>", esc(&titles.join(",")));
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{}\">{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&titles.join(","))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +320,8 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default();
|
||||
let _ = write!(w, "<workbookProtection{pw} lockStructure=\"1\"/>");
|
||||
}
|
||||
let _ = write!(w, "<bookViews><workbookView activeTab=\"{active}\"/></bookViews><sheets>{sheets_xml}</sheets>");
|
||||
w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out));
|
||||
let _ = write!(w, "<bookViews><workbookView activeTab=\"{}\"/></bookViews><sheets>{sheets_xml}</sheets>", kept.file_index(active));
|
||||
w.push_str(&kept.elements);
|
||||
if !names.is_empty() {
|
||||
let _ = write!(w, "<definedNames>{names}</definedNames>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ pub(super) struct Links {
|
|||
pub sheets: Vec<String>,
|
||||
/// Defined names (Lbl records), in order; `None` for names that can't be referenced.
|
||||
pub names: Vec<String>,
|
||||
/// Per defined name: whether the import skips it (its formula can't be decoded).
|
||||
pub skipped: Vec<bool>,
|
||||
pub books: Vec<SupBook>,
|
||||
pub xti: Vec<Xti>,
|
||||
}
|
||||
|
|
@ -330,13 +332,13 @@ pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links:
|
|||
}
|
||||
}
|
||||
0x23 => {
|
||||
let i = t.u32()?;
|
||||
let name = usize::try_from(i)
|
||||
.ok()
|
||||
.and_then(|i| i.checked_sub(1))
|
||||
.and_then(|i| links.names.get(i))
|
||||
.filter(|n| !n.is_empty())
|
||||
.ok_or(Unsupported("reference to a missing defined name"))?;
|
||||
let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1));
|
||||
let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?;
|
||||
// A cell using a name the import drops would calculate to #NAME?: keep its
|
||||
// saved result instead, as for references to other workbooks.
|
||||
if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) {
|
||||
return Err(Unsupported("defined names GridCraft skipped"));
|
||||
}
|
||||
push(&mut stack, kids, Expr::Name(name.clone()))?;
|
||||
}
|
||||
0x24 | 0x2C => {
|
||||
|
|
@ -479,6 +481,7 @@ mod tests {
|
|||
Links {
|
||||
sheets: vec!["Sheet1".into(), "My Data".into()],
|
||||
names: vec!["Rate".into()],
|
||||
skipped: vec![false],
|
||||
books: vec![
|
||||
SupBook { internal: true, addin: false, names: vec![] },
|
||||
SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] },
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
//!
|
||||
//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including
|
||||
//! shared and array formulas), number formats, the 1904 date system, merged cells, column
|
||||
//! widths, row heights, defined names, print areas and print titles. Everything else (fonts,
|
||||
//! fills, borders, charts, pictures, comments, conditional formats, validation, macros) is
|
||||
//! skipped with a warning. Encrypted workbooks and pre-BIFF8 versions are refused.
|
||||
//! widths, row heights, defined names, print areas, print titles and the calculation settings
|
||||
//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts,
|
||||
//! pictures, comments, conditional formats, validation, macros) is skipped with a warning.
|
||||
//! Encrypted workbooks and pre-BIFF8 versions are refused.
|
||||
|
||||
mod formula;
|
||||
mod ftab;
|
||||
|
|
@ -16,7 +17,7 @@ use std::collections::HashMap;
|
|||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook};
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::tables::{builtin_format, col_width_to_px, pt_to_px};
|
||||
|
|
@ -123,6 +124,7 @@ struct Globals {
|
|||
sst: Vec<Arc<str>>,
|
||||
links: Links,
|
||||
names: Vec<Lbl>,
|
||||
precision_as_displayed: bool,
|
||||
}
|
||||
|
||||
/// Reads an Excel 97-2003 workbook. Inspect the report's warnings for what was skipped.
|
||||
|
|
@ -175,18 +177,23 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo
|
|||
continue;
|
||||
}
|
||||
wb.check_sheet_name(&bs.name, None).map_err(invalid)?;
|
||||
let Some(mut sheet) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else {
|
||||
let Some((mut sheet, calc)) = worksheet(&stream, bs, &g, &styles, &mut budget, &mut report)? else {
|
||||
report.warn("Dialog sheets and unreadable sheets were skipped.");
|
||||
model_index.push(None);
|
||||
continue;
|
||||
};
|
||||
sheet.visibility = bs.visibility;
|
||||
// Every worksheet repeats the workbook's calculation settings; Excel applies the first.
|
||||
if wb.sheets.is_empty() {
|
||||
wb.calc = calc;
|
||||
}
|
||||
model_index.push(Some(wb.sheets.len()));
|
||||
wb.sheets.push(Arc::new(sheet));
|
||||
}
|
||||
if wb.sheets.is_empty() {
|
||||
return Err(invalid("the workbook has no worksheets GridCraft can read"));
|
||||
}
|
||||
wb.calc.precision_as_displayed = g.precision_as_displayed;
|
||||
defined_names(&g, &model_index, &mut wb, &mut report);
|
||||
wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
|
||||
// The overview comes first.
|
||||
|
|
@ -247,6 +254,7 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul
|
|||
g.sheets.push(BoundSheet { pos, name, visibility, kind });
|
||||
}
|
||||
0x0022 => g.date1904 = c.u16()? == 1,
|
||||
0x000E => g.precision_as_displayed = c.u16()? == 0,
|
||||
0x041E => {
|
||||
let ifmt = c.u16()?;
|
||||
let cch = usize::from(c.u16()?);
|
||||
|
|
@ -305,6 +313,9 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
// The same test `defined_names` applies, before any cell formula is decoded.
|
||||
let skipped = g.names.iter().map(|l| !l.rgce.is_empty() && decode(&l.rgce, &l.rgcb, CellRef::new(0, 0), Mode::Name, &g.links).is_err()).collect();
|
||||
g.links.skipped = skipped;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
|
|
@ -403,6 +414,7 @@ struct SheetState<'g> {
|
|||
pending_string: Option<(CellRef, StyleId)>,
|
||||
dropped: usize,
|
||||
unsupported: Vec<&'static str>,
|
||||
calc: CalcSettings,
|
||||
}
|
||||
|
||||
impl SheetState<'_> {
|
||||
|
|
@ -472,7 +484,7 @@ fn worksheet(
|
|||
styles: &[StyleId],
|
||||
budget: &mut Budget,
|
||||
report: &mut ReadReport,
|
||||
) -> Result<Option<Sheet>, IoError> {
|
||||
) -> Result<Option<(Sheet, CalcSettings)>, IoError> {
|
||||
if bs.pos >= stream.len() {
|
||||
return Err(invalid(format!("sheet '{}' points outside the Workbook stream", bs.name)));
|
||||
}
|
||||
|
|
@ -493,6 +505,7 @@ fn worksheet(
|
|||
pending_string: None,
|
||||
dropped: 0,
|
||||
unsupported: vec![],
|
||||
calc: CalcSettings::default(),
|
||||
};
|
||||
let mut depth = 1usize;
|
||||
while let Some(rec) = recs.next_record()? {
|
||||
|
|
@ -517,9 +530,9 @@ fn worksheet(
|
|||
}
|
||||
// Shared and array formulas: their definitions follow the first cell that uses them.
|
||||
let (arrays, shared) = (std::mem::take(&mut st.arrays), std::mem::take(&mut st.shared));
|
||||
for p in std::mem::take(&mut st.pending) {
|
||||
let pending = std::mem::take(&mut st.pending);
|
||||
for p in &pending {
|
||||
if let Some((range, f)) = arrays.get(&p.anchor) {
|
||||
// The other cells of an array formula keep only their values.
|
||||
if p.pos == p.anchor {
|
||||
st.attach(p.pos, f, Mode::Cell, Some(*range));
|
||||
}
|
||||
|
|
@ -529,13 +542,23 @@ fn worksheet(
|
|||
st.unsupported("a shared formula that is missing from the file");
|
||||
}
|
||||
}
|
||||
// The other cells of a legacy array formula hold only cached results: dropped, as when an
|
||||
// .xlsx is read, so that the formula fills its range on recalculation. If the formula itself
|
||||
// couldn't be imported, they keep their values.
|
||||
for p in &pending {
|
||||
let filled = st.sheet.cell(p.anchor).and_then(|c| c.formula.as_ref()).and_then(|f| f.array).is_some_and(|r| r.contains(p.pos));
|
||||
if p.pos != p.anchor && filled {
|
||||
let style = st.sheet.style_id(p.pos);
|
||||
st.sheet.cells.set(p.pos, Cell { value: Value::Empty, formula: None, style });
|
||||
}
|
||||
}
|
||||
if st.dropped > 0 {
|
||||
report.warn(format!("{} cells outside the grid were skipped.", st.dropped));
|
||||
}
|
||||
for why in &st.unsupported {
|
||||
report.warn(format!("Some formulas use {why}; their saved results were kept as values."));
|
||||
}
|
||||
Ok(Some(st.sheet))
|
||||
Ok(Some((st.sheet, st.calc)))
|
||||
}
|
||||
|
||||
fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget, report: &mut ReadReport) -> Result<(), IoError> {
|
||||
|
|
@ -632,6 +655,16 @@ fn sheet_record(rec: &Record<'_>, st: &mut SheetState<'_>, budget: &mut Budget,
|
|||
st.arrays.insert(at, (range, f));
|
||||
}
|
||||
}
|
||||
0x000D => {
|
||||
st.calc.mode = match c.u16()? as i16 {
|
||||
0 => CalcMode::Manual,
|
||||
-1 => CalcMode::AutomaticExceptTables,
|
||||
_ => CalcMode::Automatic,
|
||||
}
|
||||
}
|
||||
0x000C => st.calc.set_max_iterations(u32::from(c.u16()?)),
|
||||
0x0011 => st.calc.iterative = c.u16()? == 1,
|
||||
0x0010 => st.calc.set_max_change(c.f64()?),
|
||||
0x0236 => report.warn("What-if data tables are not supported; their values were kept."),
|
||||
0x00E5 => {
|
||||
let n = usize::from(c.u16()?);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
|
|
@ -48,7 +48,7 @@ open PRs #158, #161, and issue #190.
|
|||
| Data table under the chart | yes | **missing** |
|
||||
| Per-series / per-point fill, line, marker, effects | Format pane for every element | series colour only; Format Chart pane covers chart-level options |
|
||||
| Chart styles and colour palettes | ~16 styles × colour sets | style number stored, own palettes |
|
||||
| Chart sheets | yes | **missing** (skipped with a warning on open) |
|
||||
| Chart sheets | yes | **kept, not editable**: not shown, and written back unchanged in their place on save (#727); left out when a sheet they plot is deleted or renamed. No F11 / Move Chart > New sheet |
|
||||
| Chart templates (`.crtx`) | yes | **missing** |
|
||||
| Recommended Charts | yes | dialog with recommendations from the data's shape (own heuristic) and an All Charts list, live previews (#215) |
|
||||
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
|
||||
|
|
@ -80,6 +80,7 @@ back.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) |
|
||||
| 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` |
|
||||
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-11 | minor | Recommended Charts dialog with previews (#215) |
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@ up by *signature*, `expected type → actual type` (e.g. `number → #SPILL!`),
|
|||
|
||||
**Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is validated (every
|
||||
part has a content type, every content-type override and internal relationship target exists, relationship ids are
|
||||
unique: what Excel would "repair"), compared with the source (no kind of relationship it had, such as `xmlMaps`, is
|
||||
missing; a calculation chain doesn't count, Excel rebuilds it, nor do a link to a place in the workbook and a
|
||||
relationship to an empty part, such as a comments part without comments) and read back. Categories compared: `package`, `sheets` (names and order),
|
||||
`values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (rules
|
||||
per sheet), `validations`, `charts` (kinds and series counts), `comments`, `hyperlinks`, `pivots`. `#SPILL!`/`#CALC!`
|
||||
compare equal to `#VALUE!` and `#CIRC!` to 0, as that's what the file format stores.
|
||||
unique: what Excel would "repair") and read back. Categories compared: `package`, `sheets` (names and order),
|
||||
`values`, `formulas`, `numFmts` (per cell), `merges`, `names`, `tables` (name, range, columns), `condFormats` (each
|
||||
sheet's rules in priority order, priority numbers aside), `validations`, `charts` (kinds and series counts), `comments`,
|
||||
|
|
|
|||
|
|
@ -47,3 +47,27 @@ current or preceding frame are protected from eviction. If the visible set excee
|
|||
new images show the existing placeholder until capacity is available; header-based admission
|
||||
avoids repeatedly decoding overflow images. More than 128 small visible pictures now keep stable
|
||||
texture handles instead of cycling through the cache every frame.
|
||||
|
||||
## Ctrl+wheel zoom steps along its own ladder, not Excel's 15-point steps
|
||||
|
||||
A Ctrl+wheel notch moves the zoom to the next step on `grid::WHEEL_ZOOM_LADDER`: 10, 12, 15, 18,
|
||||
21, 25, 30, 35, 42, 50, 60, 70, 85, 100, 120, 140, 170, 200, 240, 280, 340, 400 (four steps per
|
||||
doubling, 17–25% apart). A step less than 5% away is skipped, except 100% and the 10% / 400%
|
||||
limits, which are never skipped: 121% out goes to 100%, and 99% or 101% towards 100% goes to 100%.
|
||||
The wheel's turning is counted in notches (egui eases each notch in over several frames, and
|
||||
fine-grained wheels send parts of one); a notch steps once 60% of it has turned. With animations
|
||||
on, the zoom then glides to the step over about a tenth of a second (as egui eases a scroll notch),
|
||||
and a notch turned during a glide steps on from the step it's going to. With View › Animations off
|
||||
it goes to the step at once. Whether the zoom glide should have a setting of its own is left to the
|
||||
maintainers.
|
||||
|
||||
Before, each frame multiplied the whole-number zoom by egui's eased factor and rounded, so a notch
|
||||
out never undid a notch in (100 → 122 → 101 → 123 → 102). Keeping the zoom unrounded fixed that
|
||||
for a wheel with exact notches, but not for one that sends uneven parts of a notch.
|
||||
|
||||
Excel (observed: Microsoft 365, Windows) steps 15 points a notch: 100 → 115 → 130, and from 112%
|
||||
to 115% or 100%. That's a large relative step at low zoom and a small one near 400%; equal
|
||||
relative steps keep each notch feeling the same, and the doublings land on 25, 50, 100, 200 and
|
||||
400%. The status bar's Zoom In / Zoom Out buttons and Ctrl+Alt+= / Ctrl+Alt+- keep their 10%
|
||||
steps, so the wheel and the buttons now step differently. Whether they should share one ladder
|
||||
(this one, Excel's, or another) is left to the maintainers.
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` array formulas fill their range on recalculation) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (macOS Open With offers GridCraft for `.xlsb` and `.ods`) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
|
|
@ -13,16 +18,23 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
|||
|
||||
## Workbook formats
|
||||
|
||||
Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point
|
||||
captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points,
|
||||
colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are
|
||||
not interpreted as ink. This does not implement Office InkML or fix inserted icons (#551).
|
||||
Command and package tests cover repeated saves and unrelated cell edits; external Excel rendering
|
||||
and repair-free opening remain unverified.
|
||||
|
||||
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|
||||
|---|---|---|---|---|---|
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
|
||||
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
|
||||
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
|
||||
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
|
||||
| `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles. Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed). Not read: fonts, fills, borders, alignment, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs` |
|
||||
| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — |
|
||||
| `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — |
|
||||
| `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — |
|
||||
|
|
@ -34,9 +46,9 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
|||
|
||||
| Format | Excel | GridCraft read | GridCraft write | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes | Text Import Wizard options partly via Text to Columns |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
|
||||
| `.prn` formatted text (space-delimited) | R/W | no | no | |
|
||||
| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy |
|
||||
|
|
@ -78,6 +90,11 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
|
||||
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
|
||||
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
|
||||
| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls |
|
||||
| 2026-10-12 | minor | `.xls` import: the other cells of an array formula ({=FREQUENCY(...)}, {=TREND(...)}) drop their cached results, as `.xlsx` import does, so the formula fills its range on recalculation instead of showing `#SPILL!` against its own old values; 79 fewer mismatching formulas and 16 fewer round-trip differences over 4,224 real-world files |
|
||||
| 2026-10-12 | minor | macOS: the app bundle declares `.xlsb` and `.ods`, so Finder's Open With offers GridCraft for them, as Linux and Windows already did (#321) |
|
||||
| 2026-10-12 | minor | CSV: saving a file opened as CSV keeps its detected separator (`;` files were rewritten with `,`) |
|
||||
| 2026-10-11 | minor | XLSX: cell/shared/array formulas read through the file→model conversion (`_xlpm.` LET/LAMBDA parameter prefixes stripped); table column names keep line breaks; table names may start with `\` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (TEXT and number formats: fraction literals, `?` thousands padding, booleans through the text section) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **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).
|
||||
|
|
@ -20,6 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function
|
|||
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
|
||||
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
|
||||
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
|
||||
- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR),
|
||||
is in our library as a Compatibility function for files that use it. It is not counted above.
|
||||
- **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no
|
||||
cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks
|
||||
WEBSERVICE and FILTERXML; Excel for Windows has them.
|
||||
|
|
@ -106,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
|
||||
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
|
||||
| 2026-10-11 | minor | Trim-reference operators (`:.`, `.:`, `.:.`) and unbracketed `[[#This Row],Col]` done; dropped the stale "Scope of what's missing" rows for TRIMRANGE, PERCENTOF, BAHTTEXT and the trim references |
|
||||
|
|
|
|||
11
docs/gaps.md
11
docs/gaps.md
|
|
@ -1,5 +1,6 @@
|
|||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (ink XLSX preservation; icons still pending) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (§8: PivotTable values on rows) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
|
|
@ -106,6 +107,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
data model (`data.fromWeb`, `data.relationships` missing). **Estimate:** 60–100 h.
|
||||
|
||||
### 13. Analysis tools: Solver, Analysis ToolPak, Forecast Sheet (F)
|
||||
- `data.solver`, `data.analysisToolPak` missing. Forecast Sheet (`data.forecastSheet`) and the
|
||||
FORECAST.ETS family are in, including Forecast Start (hindcast, #785). **Estimate:** 15–25 h.
|
||||
- Solver (`data.solver`, #327) has Simplex LP with integer/binary branch and bound, a local
|
||||
nonlinear method (augmented Lagrangian, not GRG), the Evolutionary method, and Answer and
|
||||
Limits reports; AllDifferent constraints, the Solver Options dialog, the Sensitivity and
|
||||
|
|
@ -115,8 +118,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
|
||||
### 14. Page Layout view and print polish (U, F)
|
||||
- Page Layout view landed (#329): paper view with rulers and in-place header/footer editing.
|
||||
Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging
|
||||
margins on the ruler. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h.
|
||||
Margins are dragged on the rulers (#792). Remaining: pictures in headers/footers, Different First Page / Odd & Even pages. Desktop now submits directly through craft-print with printer choice, copies, duplex and grayscale; browser printing stays available. Print ranges and driver tray/options UI remain. **Estimate:** 5–10 h.
|
||||
|
||||
### 15. Remaining functions and an oracle suite (F)
|
||||
- 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h.
|
||||
|
|
@ -130,6 +132,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
conversion; cloud storage requested (#86). **Estimate:** 35–55 h (co-authoring scope needs the owner).
|
||||
|
||||
### 17. Insert objects (F)
|
||||
- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their
|
||||
identity/artwork on save (#551); native Office InkML import is not implemented.
|
||||
- SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs
|
||||
~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h.
|
||||
|
||||
|
|
@ -165,6 +169,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder |
|
||||
| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) |
|
||||
| 2026-10-12 | minor | §17: ink geometry survives XLSX saves; the icon portion of #551 remains |
|
||||
| 2026-10-12 | minor | §8: values on rows (Σ Values in Rows, `dataOnRows`) laid out, moved and round-tripped |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese catalog: dialogs, menus and tooltips) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
|
||||
|
|
@ -14,7 +15,8 @@ right-to-left sheets and full complex-script support. Part of
|
|||
system language (closes #20) and a View-tab switcher, with English-keyed lookup tables:
|
||||
Japanese 268 labels, Simplified Chinese, Korean, Russian, Brazilian Portuguese, German and Czech 266
|
||||
each. The tables are used by
|
||||
the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`); dialogs, menus,
|
||||
the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells
|
||||
dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus,
|
||||
context menus, task panes, status messages, command labels and function descriptions are still
|
||||
English literals. Untranslated labels fall back to English.
|
||||
- **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command
|
||||
|
|
@ -77,6 +79,7 @@ language (none so far) and of localized function names.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them |
|
||||
| 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) |
|
||||
| 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |
|
||||
| 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) |
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue