diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index edcacf57d..e99a13c8f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -232,13 +232,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result { } fn print_titles(s: &mut Session, p: &Json) -> Result { - let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row)); - let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col)); + // An absent key leaves that axis alone, and an empty or null value clears it — the same + // reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a + // key that was never sent used to wipe the other axis. + let axis = |key: &str, is_cols: bool| -> Result>> { + match p.get(key) { + None => Ok(None), + Some(Json::Null) => Ok(Some(None)), + Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)), + Some(Json::String(t)) => match RangeRef::parse(t) { + Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))), + None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))), + }, + Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))), + } + }; + let (rows, cols) = (axis("rows", false)?, axis("cols", true)?); edit(s, |cx| { let si = cx.sheet_index(); let pr = &mut cx.sheet_mut(si)?.print; - pr.title_rows = rows; - pr.title_cols = cols; + if let Some(t) = rows { + pr.title_rows = t; + } + if let Some(t) = cols { + pr.title_cols = t; + } Ok(Json::Null) }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..f7d0cec74 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2961,6 +2961,33 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +/// Page Layout ▸ Print Titles: naming one axis must not clear the other. `print_titles` read +/// both `rows` and `cols` into `Option`s and assigned both unconditionally, so +/// `{"rows":"2:2"}` silently dropped the repeated columns from every printed page. +#[test] +fn print_titles_keeps_the_axis_it_is_not_given() { + let mut s = s(); + s.execute("pageLayout.printTitles", json!({"rows": "1:1", "cols": "A:A"})).unwrap(); + let titles = |s: &Session| { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + (sh.print.title_rows, sh.print.title_cols) + }; + assert_eq!(titles(&s), (Some((0, 0)), Some((0, 0)))); + // Only rows: the columns stay. + s.execute("pageLayout.printTitles", json!({"rows": "2:2"})).unwrap(); + assert_eq!(titles(&s), (Some((1, 1)), Some((0, 0))), "rows alone must not clear the columns"); + // Only columns: the rows stay. + s.execute("pageLayout.printTitles", json!({"cols": "B:B"})).unwrap(); + assert_eq!(titles(&s), (Some((1, 1)), Some((1, 1))), "columns alone must not clear the rows"); + // An empty value clears that axis on purpose. + s.execute("pageLayout.printTitles", json!({"cols": ""})).unwrap(); + assert_eq!(titles(&s), (Some((1, 1)), None)); + // A bad range is an error, not a silent wipe. + assert!(s.execute("pageLayout.printTitles", json!({"rows": "not a range"})).is_err()); + assert_eq!(titles(&s), (Some((1, 1)), None)); +} + /// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is /// a place in this document, anything else an existing file or web page. A file saved as a /// `location` opens nothing in Excel (#800).