Headers and footers: first-page and even-page variants, Insert › Header & Footer edits in place (#344)
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 <noreply@anthropic.com>
This commit is contained in:
parent
1d3abfa4a7
commit
cdd71b4d5d
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)) = §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,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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í",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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("<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));
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
if !p.footer.is_empty() {
|
||||
let _ = write!(s, "<oddFooter>{}</oddFooter>", esc(&p.footer));
|
||||
}
|
||||
s.push_str("</headerFooter>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue