From 18fecf8e8ce05afa01271779ab66562109ceb16f Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:16:52 +0300 Subject: [PATCH] engine: pageLayout.printTitles only touches the axes it is given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit print_titles read both rows and cols as Options and assigned both unconditionally, so {"rows":"2:2"} set title_cols = None and {"cols":"A:A"} set title_rows = None: the repeated rows or columns disappeared from every printed page with no message, and there was no way to set or clear just one axis. The sibling pageLayout.pageSetup already distinguishes "key absent" from "key present but empty"; read both axes the same way — absent leaves the axis alone, empty/null clears it, anything else that is not a range is an error rather than a silent wipe. Regression test: print_titles_keeps_the_axis_it_is_not_given — on main the rows-only call clears the columns; with the fix each axis is touched only when named, an explicit empty value still clears, and a bad range errors. --- crates/engine/src/cmd/view.rs | 26 ++++++++++++++++++++++---- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) 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 ae499a189..503f08d87 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2925,6 +2925,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).