Merge pull request #404 from vangel76/fix/header-footer-variants

Headers and footers: first-page and even-page variants, Insert › Header & Footer edits in place (#344)

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Brandon Thomas 2026-10-11 16:08:56 -04:00
commit 4a23b9cf4b
19 changed files with 338 additions and 26 deletions

View File

@ -141,6 +141,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-11 | Protect Sheet passwords use Excel-compatible hashes and round-trip in XLSX `sheetProtection` (imported passwords verify instead of being dropped) |
| 2026-10-11 | Review › Protect Workbook opens a dialog with an optional password (Excel-compatible hash, kept in XLSX `workbookProtection`); unprotecting verifies it (#345) |
| 2026-10-11 | Formula bar, name box and dialogs fall back to the cell font: Hebrew (and any script cells can show) no longer turns into boxes there (#349) |
| 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) |

View File

@ -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();

View File

@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Json> {
"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<Json> {
if let Some(f) = str_param(p, "footer") {
pr.footer = cap(f);
}
if let Some((footer, idx, text)) = &section {
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)) = &section {
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,
}))
})
}

View File

@ -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<u32>,
pub col_breaks: Vec<u32>,
/// 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<HfPage> {
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,
}
}
}

View File

@ -100,6 +100,8 @@ 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>,
/// 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>,
}

View File

@ -486,6 +486,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.

View File

@ -199,6 +199,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í",

View File

@ -199,6 +199,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",

View File

@ -200,6 +200,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",

View File

@ -323,6 +323,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.

View File

@ -324,6 +324,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.

View File

@ -323,6 +323,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.

View File

@ -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.

View File

@ -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) {

View File

@ -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| {

View File

@ -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"));
}

View File

@ -137,8 +137,22 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
}
}
"headerFooter" => {
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(),

View File

@ -449,13 +449,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("<headerFooter>");
if !p.header.is_empty() {
let _ = write!(s, "<oddHeader>{}</oddHeader>", 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("<headerFooter");
for (attr, on, default) in [
("differentOddEven", p.different_odd_even, false),
("differentFirst", p.different_first, false),
("scaleWithDoc", p.hf_scale_with_doc, true),
("alignWithMargins", p.hf_align_with_margins, true),
] {
if on != default {
let _ = write!(s, " {attr}=\"{}\"", u8::from(on));
}
}
if !p.footer.is_empty() {
let _ = write!(s, "<oddFooter>{}</oddFooter>", 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}>{}</{tag}>", esc(text));
}
}
s.push_str("</headerFooter>");
}

View File

@ -273,6 +273,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 {