From cdd71b4d5d72fc21775966c7d933e4622330928d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 21:44:49 +0200 Subject: [PATCH] =?UTF-8?q?Headers=20and=20footers:=20first-page=20and=20e?= =?UTF-8?q?ven-page=20variants,=20Insert=20=E2=80=BA=20Header=20&=20Footer?= =?UTF-8?q?=20edits=20in=20place=20(#344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XLSX headerFooter kept only oddHeader/oddFooter: a workbook whose header is on a different first page (differentFirst + firstHeader) or on even pages showed no header, and saving dropped it. The model now keeps the first/even headers and footers and the differentFirst / differentOddEven / scaleWithDoc / alignWithMargins flags; XLSX reads and writes them; the PDF and the Page Layout view use the right pair per page; in-place editing edits the page's own pair, and the Header & Footer tab has Different First Page and Different Odd & Even Pages. pageLayout.headerFooter takes the variants and a page: odd|even|first for one-section edits. Insert › Header & Footer now switches to Page Layout view and edits the first page's centre header, as in Excel, instead of opening the codes dialog. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/print.rs | 34 ++++++++- crates/engine/src/cmd/view.rs | 39 ++++++++-- crates/model/src/features.rs | 96 ++++++++++++++++++++++++ crates/ui-egui/src/grid.rs | 2 + crates/ui-egui/src/i18n.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/paper_view.rs | 79 +++++++++++++++++-- crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/tests/page_layout_view.rs | 38 ++++++++++ crates/xlsx/src/sheet_read.rs | 18 ++++- crates/xlsx/src/sheet_write.rs | 30 ++++++-- crates/xlsx/src/tests/roundtrip.rs | 8 ++ 19 files changed, 338 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 03830fb..f890e26 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | Headers and footers: first-page and odd/even variants are read, kept, printed and edited in Page Layout view; Insert › Header & Footer edits the header in place (#344) | | 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) | | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index aead379..777c3cf 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1353,11 +1353,11 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: } } -fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, cx: &HfContext) { +fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) { let [l, r, _, _, hm, fm] = lay.margins; let pw = lay.page_w; let ph = lay.page_h; - for (spec, header) in [(&lay.settings.header, true), (&lay.settings.footer, false)] { + for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] { if spec.is_empty() { continue; } @@ -1433,7 +1433,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; let k = lay.k(); let (aw, ah) = lay.avail(); - for (ri, ci) in &lay.pages { + for (pi, (ri, ci)) in lay.pages.iter().enumerate() { page_no += 1; let (Some(rband), Some(cband)) = (lay.row_bands.get(*ri).copied(), lay.col_bands.get(*ci).copied()) else { continue }; let rsegs = segments(lay.title_rows, rband); @@ -1477,7 +1477,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str date: date.clone(), time: time.clone(), }; - draw_header_footer(pg, lay, &cx); + draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx); } } let n = doc.page_count(); @@ -1826,6 +1826,32 @@ mod tests { assert_eq!(pv["pageList"][2]["range"], "K1:O45"); } + #[test] + fn first_and_even_pages_print_their_own_headers() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"], ["d"], ["e"], ["f"]]})).unwrap(); + s.execute("pageLayout.pageSetup", json!({"rowBreaks": [2, 4]})).unwrap(); + let r = s + .execute( + "pageLayout.headerFooter", + json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}), + ) + .unwrap(); + assert_eq!(r["firstHeader"], "&CCover"); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 3); + assert!(contains(&pdf, "(Cover) Tj") && contains(&pdf, "(Even 2) Tj") && contains(&pdf, "(Odd 3) Tj")); + assert!(!contains(&pdf, "(Odd 1) Tj"), "the first page has its own header"); + // One section of the even-page footer, edited in place. + s.execute("pageLayout.headerFooter", json!({"page": "even", "part": "footer", "section": "right", "text": "&[Tab]"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.even_footer, "&R&A"); + assert!(s.execute("pageLayout.headerFooter", json!({"page": "last", "part": "header", "text": "x"})).is_err()); + // Turned off, every page prints the odd header again. + s.execute("pageLayout.headerFooter", json!({"differentFirst": false, "differentOddEven": false})).unwrap(); + let (pdf, _) = pdf_of(&mut s, json!({})); + assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj")); + } + #[test] fn pdf_contains_text_and_header_codes_expand() { let mut s = s(); diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 747cb73..edcacf5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -96,7 +96,7 @@ pub fn specs() -> Vec { "Header & Footer", ["Insert", "Text"], None, - "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}", + "{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}", has_doc, header_footer ), @@ -308,7 +308,11 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { "right" => 2, other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))), }; - Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or("")))) + let page = match str_param(p, "page") { + None => gridcraft_model::HfPage::Odd, + Some(id) => gridcraft_model::HfPage::parse(id).ok_or_else(|| bad(CMD, format!("`page` must be odd, even or first, not `{id}`")))?, + }; + Some((page, footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or("")))) } }; edit(s, |cx| { @@ -319,15 +323,40 @@ fn header_footer(s: &mut Session, p: &Json) -> Result { if let Some(f) = str_param(p, "footer") { pr.footer = cap(f); } - if let Some((footer, idx, text)) = §ion { - let spec = if *footer { &mut pr.footer } else { &mut pr.header }; + for (key, slot) in [ + ("firstHeader", &mut pr.first_header), + ("firstFooter", &mut pr.first_footer), + ("evenHeader", &mut pr.even_header), + ("evenFooter", &mut pr.even_footer), + ] { + if let Some(t) = str_param(p, key) { + *slot = cap(t); + } + } + if let Some(on) = bool_param(p, "differentFirst") { + pr.different_first = on; + } + if let Some(on) = bool_param(p, "differentOddEven") { + pr.different_odd_even = on; + } + if let Some((page, footer, idx, text)) = §ion { + let spec = pr.hf_mut(*page, *footer); let mut parts = super::print::split_header_footer(spec); if let Some(slot) = parts.get_mut(*idx) { *slot = text.clone(); } *spec = cap(&super::print::join_header_footer(&parts)); } - Ok(json!({"header": pr.header, "footer": pr.footer})) + Ok(json!({ + "header": pr.header, + "footer": pr.footer, + "differentFirst": pr.different_first, + "differentOddEven": pr.different_odd_even, + "firstHeader": pr.first_header, + "firstFooter": pr.first_footer, + "evenHeader": pr.even_header, + "evenFooter": pr.even_footer, + })) }) } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 317476e..8a846c4 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -543,10 +543,98 @@ pub struct PrintSettings { pub headings: bool, pub center_h: bool, pub center_v: bool, + /// The header and footer of odd pages, and of every page unless the variants below apply. pub header: String, pub footer: String, pub row_breaks: Vec, pub col_breaks: Vec, + /// Different First Page: the sheet's first printed page uses `first_header`/`first_footer`. + #[serde(default)] + pub different_first: bool, + /// Different Odd & Even Pages: even-numbered pages use `even_header`/`even_footer`. + #[serde(default)] + pub different_odd_even: bool, + #[serde(default)] + pub first_header: String, + #[serde(default)] + pub first_footer: String, + #[serde(default)] + pub even_header: String, + #[serde(default)] + pub even_footer: String, + /// Scale with Document / Align with Page Margins (both on unless a file turned them off). + #[serde(default = "yes")] + pub hf_scale_with_doc: bool, + #[serde(default = "yes")] + pub hf_align_with_margins: bool, +} + +fn yes() -> bool { + true +} + +/// Which header/footer pair a printed page uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HfPage { + Odd, + Even, + First, +} + +impl HfPage { + pub fn id(self) -> &'static str { + match self { + HfPage::Odd => "odd", + HfPage::Even => "even", + HfPage::First => "first", + } + } + pub fn parse(id: &str) -> Option { + match id { + "odd" | "all" => Some(HfPage::Odd), + "even" => Some(HfPage::Even), + "first" => Some(HfPage::First), + _ => None, + } + } +} + +impl PrintSettings { + /// The pair used by page `number` (1-based, as `&P` prints it); `first` is the sheet's first page. + pub fn hf_page(&self, first: bool, number: usize) -> HfPage { + if self.different_first && first { + HfPage::First + } else if self.different_odd_even && number.is_multiple_of(2) { + HfPage::Even + } else { + HfPage::Odd + } + } + /// The header (`footer` false) or footer of a page kind. + pub fn hf(&self, page: HfPage, footer: bool) -> &String { + match (page, footer) { + (HfPage::Odd, false) => &self.header, + (HfPage::Odd, true) => &self.footer, + (HfPage::Even, false) => &self.even_header, + (HfPage::Even, true) => &self.even_footer, + (HfPage::First, false) => &self.first_header, + (HfPage::First, true) => &self.first_footer, + } + } + pub fn hf_mut(&mut self, page: HfPage, footer: bool) -> &mut String { + match (page, footer) { + (HfPage::Odd, false) => &mut self.header, + (HfPage::Odd, true) => &mut self.footer, + (HfPage::Even, false) => &mut self.even_header, + (HfPage::Even, true) => &mut self.even_footer, + (HfPage::First, false) => &mut self.first_header, + (HfPage::First, true) => &mut self.first_footer, + } + } + /// Any header or footer text at all (on any kind of page). + pub fn has_header_footer(&self) -> bool { + [&self.header, &self.footer, &self.first_header, &self.first_footer, &self.even_header, &self.even_footer].iter().any(|t| !t.is_empty()) + } } impl Default for PrintSettings { @@ -568,6 +656,14 @@ impl Default for PrintSettings { footer: String::new(), row_breaks: vec![], col_breaks: vec![], + different_first: false, + different_odd_even: false, + first_header: String::new(), + first_footer: String::new(), + even_header: String::new(), + even_footer: String::new(), + hf_scale_with_doc: true, + hf_align_with_margins: true, } } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 820acf7..af61a7a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -100,6 +100,8 @@ pub struct GridState { pub paper: Option, /// Header or footer being edited in place (Page Layout view). pub hf_edit: Option, + /// 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, } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9b2831c..dfc6058 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -484,6 +484,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("File Name", "ファイル名"), ("Sheet Name", "シート名"), ("Go to Header", "ヘッダーに移動"), + ("Different First Page", "先頭ページのみ別指定"), + ("Different Odd & Even Pages", "奇数/偶数ページ別指定"), ("Go to Footer", "フッターに移動"), ("Ruler", "ルーラー"), // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 65e9def..feadd9a 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -197,6 +197,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File\nPath" => "Cesta\nk souboru", "File Path" => "Cesta k souboru", "Footer" => "Zápatí", + "Different First Page" => "Jiná první stránka", + "Different Odd & Even Pages" => "Jiné liché a sudé stránky", "Go to Footer" => "Přejít na zápatí", "Go to Header" => "Přejít na záhlaví", "Go to\nFooter" => "Přejít\nna zápatí", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 5cb9881..390c196 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -197,6 +197,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File\nPath" => "Datei-\npfad", "File Path" => "Dateipfad", "Footer" => "Fußzeile", + "Different First Page" => "Erste Seite anders", + "Different Odd & Even Pages" => "Gerade und ungerade Seiten anders", "Go to Footer" => "Zur Fußzeile wechseln", "Go to Header" => "Zur Kopfzeile wechseln", "Go to\nFooter" => "Zur\nFußzeile", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index b30a1d1..06f3eaa 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -198,6 +198,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File\nPath" => "Ruta de\narchivo", "File Path" => "Ruta de archivo", "Footer" => "Pie de página", + "Different First Page" => "Primera página diferente", + "Different Odd & Even Pages" => "Páginas pares e impares diferentes", "Go to Footer" => "Ir al pie de página", "Go to Header" => "Ir al encabezado", "Go to\nFooter" => "Ir al pie\nde página", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index fb33cbd..8999dad 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -321,6 +321,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File Name" => "파일 이름", "Sheet Name" => "시트 이름", "Go to Header" => "머리글로 이동", + "Different First Page" => "첫 페이지를 다르게 지정", + "Different Odd & Even Pages" => "짝수와 홀수 페이지를 다르게 지정", "Go to Footer" => "바닥글로 이동", "Ruler" => "눈금자", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 35f1336..0bfc00d 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -322,6 +322,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File Name" => "Nome do Arquivo", "Sheet Name" => "Nome da Planilha", "Go to Header" => "Ir para o Cabeçalho", + "Different First Page" => "Primeira Página Diferente", + "Different Odd & Even Pages" => "Páginas Pares e Ímpares Diferentes", "Go to Footer" => "Ir para o Rodapé", "Ruler" => "Régua", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e50e5ff..4e5077e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -321,6 +321,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File Name" => "Имя файла", "Sheet Name" => "Имя листа", "Go to Header" => "Перейти к верхнему колонтитулу", + "Different First Page" => "Особый колонтитул для первой страницы", + "Different Odd & Even Pages" => "Разные колонтитулы для четных и нечетных страниц", "Go to Footer" => "Перейти к нижнему колонтитулу", "Ruler" => "Линейка", // File › Backstage view. diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index db71a16..316c9d1 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -319,6 +319,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "File Name" => "文件名", "Sheet Name" => "工作表名", "Go to Header" => "转至页眉", + "Different First Page" => "首页不同", + "Different Odd & Even Pages" => "奇偶页不同", "Go to Footer" => "转至页脚", "Ruler" => "标尺", // File › Backstage view. diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 1546bbb..110193a 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use egui::{Align2, Color32, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2}; use gridcraft_engine::cf::CfCache; use gridcraft_engine::cmd::print::{self, HfContext}; -use gridcraft_engine::model::{Sheet, SheetViewMode, Workbook}; +use gridcraft_engine::model::{HfPage, Sheet, SheetViewMode, Workbook}; use gridcraft_engine::paper::{self, HfZone, PageGrid}; use serde_json::json; @@ -40,6 +40,8 @@ pub struct HfEdit { /// Page (row band, column band) whose header/footer is shown in edit. pub page: (usize, usize), pub footer: bool, + /// Which header/footer pair the page uses (first page, even pages, the rest). + pub kind: HfPage, /// 0 left, 1 centre, 2 right. pub section: usize, /// The three sections as the user sees them (`&[Page]` field names). @@ -210,14 +212,15 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, (geo.scroll.x as f64 + ((q.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((q.y - geo.cells.top()) / geo.z) as f64); pg.hf_zone_at(x, y) }); - let needs_text = !sh.print.header.is_empty() || !sh.print.footer.is_empty(); + let needs_text = sh.print.has_header_footer(); let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() }; for (i, j) in pages_iter(geo, &pg) { let page = screen_rect(geo, pg.page_rect(i, j)); if !page.intersects(geo.cells) { continue; } - for (spec, footer) in [(&sh.print.header, false), (&sh.print.footer, true)] { + let kind = page_kind(sh, &pg, (i, j)); + for (spec, footer) in [(sh.print.hf(kind, false), false), (sh.print.hf(kind, true), true)] { if editing == Some(((i, j), footer)) { continue; } @@ -281,6 +284,9 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo commit(app); } let Some(pg) = geo.paper.clone() else { return }; + 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 }); + } if resp.clicked_by(egui::PointerButton::Primary) && let Some(p) = ui.input(|i| i.pointer.interact_pos()) { @@ -299,6 +305,43 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo editor(app, ui, geo, &pg); } +/// The `pageLayout.headerFooter` parameter holding one kind of page's header or footer. +fn hf_key(kind: HfPage, footer: bool) -> &'static str { + match (kind, footer) { + (HfPage::Odd, false) => "header", + (HfPage::Odd, true) => "footer", + (HfPage::Even, false) => "evenHeader", + (HfPage::Even, true) => "evenFooter", + (HfPage::First, false) => "firstHeader", + (HfPage::First, true) => "firstFooter", + } +} + +/// Insert › Header & Footer: switches to Page Layout view and edits the first page's centre header. +pub fn start_editing(app: &mut SheetApp) { + if app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.view != SheetViewMode::PageLayout) { + app.run_or_alert("view.pageLayout", json!({})); + } + app.grid.hf_start = true; +} + +/// Turns Different First Page / Different Odd & Even Pages on or off, keeping the edit on the +/// same page (which may now show another header). +fn set_option(app: &mut SheetApp, key: &str, on: bool) { + let Some(e) = app.grid.hf_edit.as_ref() else { return }; + let (sheet, page, zone) = (e.sheet, e.page, HfZone { footer: e.footer, section: e.section }); + commit(app); + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, key: on})); + let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(sheet)).cloned() else { return }; + begin(app, sheet, &sh, page, zone); +} + +/// Which header/footer pair a page shows: the first page, an even page, or the rest. +fn page_kind(sh: &Sheet, pg: &PageGrid, page: (usize, usize)) -> HfPage { + let n = pg.page_number(page.0, page.1).map_or(1, |(n, _)| n); + sh.print.hf_page(n == 1, n) +} + /// Starts (or moves) editing a header or footer section. pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), zone: HfZone) { if let Some(e) = app.grid.hf_edit.as_mut() @@ -321,14 +364,19 @@ pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), if app.editor.is_some() && !app.commit_edit(0, 0, false, false) { return; } - let spec = if zone.footer { &sh.print.footer } else { &sh.print.header }; - let texts = print::split_header_footer(spec).map(|s| print::codes_to_fields(&s)); + // Before the view has paginated this sheet, the top-left page is taken as the first. + let kind = match app.grid.paper.as_ref().filter(|(key, _)| key.2 == sheet) { + Some((_, pg)) => page_kind(sh, pg, page), + None => sh.print.hf_page(page == (0, 0), 1), + }; + let texts = print::split_header_footer(sh.print.hf(kind, zone.footer)).map(|s| print::codes_to_fields(&s)); let caret = texts.get(zone.section).map_or(0, |t| t.chars().count()); let prev_tab = if app.ui.ribbon_tab == TAB { "Home".to_string() } else { app.ui.ribbon_tab.clone() }; app.grid.hf_edit = Some(HfEdit { sheet, page, footer: zone.footer, + kind, section: zone.section.min(2), original: texts.clone(), texts, @@ -351,7 +399,7 @@ pub fn commit(app: &mut SheetApp) { return; } let spec = print::join_header_footer(&e.texts.clone().map(|s| print::fields_to_codes(&s))); - let key = if e.footer { "footer" } else { "header" }; + let key = hf_key(e.kind, e.footer); app.run_or_alert("pageLayout.headerFooter", json!({"sheet": e.sheet, key: spec})); } @@ -550,7 +598,7 @@ pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { use crate::widgets::big_button; let Some(e) = app.grid.hf_edit.as_ref() else { return }; - let footer = e.footer; + let (footer, kind) = (e.footer, e.kind); let (sheet_name, file) = app.session.active().map(|d| (d.wb.sheet(e.sheet).map(|s| s.name.clone()).unwrap_or_default(), d.display_title())).unwrap_or_default(); // Header & Footer presets (shown with sample values, as the result will read). @@ -570,7 +618,7 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { if ui.button(text).clicked() { let sheet = app.grid.hf_edit.as_ref().map(|e| e.sheet).unwrap_or(0); cancel(app); - app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, (if part { "footer" } else { "header" }): spec})); + app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, hf_key(kind, part): spec})); ui.close(); } } @@ -597,6 +645,21 @@ pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) { if big_button(ui, Icon::Footer, "Go to\nFooter", "Go to Footer", false).clicked() && !footer { go_to(app, true); } + sep(ui); + let (first, odd_even) = app + .session + .active() + .and_then(|d| d.wb.sheet(app.grid.hf_edit.as_ref().map_or(0, |e| e.sheet)).map(|s| (s.print.different_first, s.print.different_odd_even))) + .unwrap_or_default(); + ui.vertical(|ui| { + let (mut f, mut o) = (first, odd_even); + if ui.checkbox(&mut f, tr(app, "Different First Page")).changed() { + set_option(app, "differentFirst", f); + } + if ui.checkbox(&mut o, tr(app, "Different Odd & Even Pages")).changed() { + set_option(app, "differentOddEven", o); + } + }); } fn sep(ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f91c8d3..6886495 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1295,7 +1295,8 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { act(app, "insert.textBox", json!({})); } if big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false).clicked() { - app.open_dialog("headerFooter", json!({})); + // As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup). + crate::paper_view::start_editing(app); } let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true); egui::Popup::menu(&sym).show(|ui| { diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/page_layout_view.rs index d212d05..f8850b3 100644 --- a/crates/ui-egui/tests/page_layout_view.rs +++ b/crates/ui-egui/tests/page_layout_view.rs @@ -124,3 +124,41 @@ fn header_is_edited_in_place_committed_by_a_click_elsewhere_and_undoable() { assert!(h.state().grid.hf_edit.is_none()); assert_eq!(header(&h), "&LLeft"); } + +#[test] +fn the_first_page_edits_its_own_header_when_different_first_page_is_on() { + let mut h = fixture(); + h.state_mut().run("pageLayout.headerFooter", json!({"differentFirst": true, "firstHeader": "&CCover"})).unwrap(); + h.run_steps(3); + // Insert › Header & Footer edits the first page's centre header, here the first-page one. + gridcraft_ui_egui::paper_view::start_editing(h.state_mut()); + h.run_steps(3); + { + let e = h.state().grid.hf_edit.as_ref().expect("Insert › Header & Footer starts editing"); + assert_eq!((e.footer, e.section, e.kind), (false, 1, gridcraft_engine::model::HfPage::First)); + assert_eq!(e.texts[1], "Cover"); + } + h.input_mut().events.push(Event::Text(" page".into())); + h.run_steps(2); + let g = geo(&h); + let b3 = { + let app = h.state(); + g.cell_rect(app.session.doc().unwrap().wb.active().unwrap(), CellRef::parse("B3").unwrap()).center() + }; + click(&mut h, b3); + let pr = h.state().session.doc().unwrap().wb.active().unwrap().print.clone(); + assert_eq!(pr.first_header, "&CCover page"); + assert_eq!(pr.header, "&LLeft", "the other pages keep their header"); +} + +#[test] +fn insert_header_and_footer_switches_to_page_layout_view() { + let mut h = fixture(); + h.state_mut().run("view.normal", json!({})).unwrap(); + h.run_steps(3); + gridcraft_ui_egui::paper_view::start_editing(h.state_mut()); + h.run_steps(4); + assert_eq!(h.state().session.doc().unwrap().wb.active().unwrap().view, SheetViewMode::PageLayout); + 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")); +} diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 07ee340..82d5b6c 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -137,8 +137,22 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result { - sheet.print.header = e.child("oddHeader").map(|h| h.text.clone()).unwrap_or_default(); - sheet.print.footer = e.child("oddFooter").map(|h| h.text.clone()).unwrap_or_default(); + let pr = &mut sheet.print; + for (tag, slot) in [ + ("oddHeader", &mut pr.header), + ("oddFooter", &mut pr.footer), + ("evenHeader", &mut pr.even_header), + ("evenFooter", &mut pr.even_footer), + ("firstHeader", &mut pr.first_header), + ("firstFooter", &mut pr.first_footer), + ] { + // Excel caps each at 255 characters; a hostile file can't make them huge. + *slot = e.child(tag).map(|h| h.text.chars().take(255).collect()).unwrap_or_default(); + } + pr.different_first = e.flag("differentFirst", false); + pr.different_odd_even = e.flag("differentOddEven", false); + pr.hf_scale_with_doc = e.flag("scaleWithDoc", true); + pr.hf_align_with_margins = e.flag("alignWithMargins", true); } "rowBreaks" => sheet.print.row_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&r| r > 0 && r < MAX_ROWS).collect(), "colBreaks" => sheet.print.col_breaks = e.kids("brk").filter_map(|b| b.attr_u32("id")).filter(|&c| c > 0 && c < MAX_COLS).collect(), diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index fee804c..e789b7f 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -442,13 +442,31 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( let _ = write!(s, " r:id=\"{id}\""); } s.push_str("/>"); - if !p.header.is_empty() || !p.footer.is_empty() { - s.push_str(""); - if !p.header.is_empty() { - let _ = write!(s, "{}", esc(&p.header)); + if p.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins { + s.push_str("{}", esc(&p.footer)); + s.push('>'); + // CT_HeaderFooter's element order. + for (tag, text) in [ + ("oddHeader", &p.header), + ("oddFooter", &p.footer), + ("evenHeader", &p.even_header), + ("evenFooter", &p.even_footer), + ("firstHeader", &p.first_header), + ("firstFooter", &p.first_footer), + ] { + if !text.is_empty() { + let _ = write!(s, "<{tag}>{}", esc(text)); + } } s.push_str(""); } diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 2385b8e..c2a8163 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -265,6 +265,14 @@ fn sample() -> Workbook { footer: "&C&F".into(), row_breaks: vec![15, 30], col_breaks: vec![4], + different_first: true, + different_odd_even: true, + first_header: "&CCover".into(), + first_footer: String::new(), + even_header: "&LEven &P".into(), + even_footer: "&R&A".into(), + hf_scale_with_doc: false, + hf_align_with_margins: true, }; s.sparklines.push(Sparkline { cell: at("J2"), source: "B2:B3".into(), kind: SparklineKind::WinLoss, color: Color::Rgb(0x112233), markers: true }); s.tables.push(Table {