فهرست منبع

Align Office pagination and header/footer modes

Reworked Docs PDF export into a CSS-like block/inline layout pipeline so wrapping, margin collapsing, block pagination, tables/lists/pre blocks, and image sizing match the editor preview more closely. Added `hfMode` (`all`, `except-first`, `none`) end-to-end in Docs state/UI, DOCX/ODT readers/writers, and PDF rendering (including first-page page-number suppression behavior), with expanded regression tests for layout and header/footer round-trips. Also added desktop/icon PNG assets for AGIForge and GitApp, and updated Office docs to document the new behavior.
Toby Chui 3 هفته پیش
والد
کامیت
57b4e3ecda

+ 28 - 0
src/mod/office/docx.go

@@ -24,6 +24,34 @@ type Document struct {
 	Header      string    `json:"header,omitempty"`
 	Footer      string    `json:"footer,omitempty"`
 	PageNumbers bool      `json:"pageNumbers,omitempty"`
+	HFMode      string    `json:"hfMode,omitempty"` // header/footer repetition
+}
+
+// Header/footer repetition modes (body.hfMode); the empty string means
+// HFModeAll, which is what documents written before the setting existed get
+const (
+	HFModeAll         = "all"          // every page carries the same text
+	HFModeExceptFirst = "except-first" // every page but the first
+	HFModeNone        = "none"         // no header/footer text at all
+)
+
+// hfOnPage reports whether the header/footer text shows on the given
+// 1-based page number under mode
+func hfOnPage(mode string, page int) bool {
+	switch mode {
+	case HFModeNone:
+		return false
+	case HFModeExceptFirst:
+		return page > 1
+	}
+	return true
+}
+
+// hfPageNumberOn reports whether the page counter shows on the given page:
+// it is its own page-setup option, but a suppressed first page suppresses
+// its number too (the same thing Word's titlePg does)
+func hfPageNumberOn(doc *Document, page int) bool {
+	return doc.PageNumbers && (doc.HFMode != HFModeExceptFirst || page > 1)
 }
 
 // PageConf holds page geometry (margins in millimetres)

+ 5 - 0
src/mod/office/docx_reader.go

@@ -107,6 +107,11 @@ func ParseDocx(data []byte) (*Document, error) {
 			}
 			pc.Margins = m
 		}
+		if sect.first("titlePg") != nil {
+			// "different first page" with no first-page part: the editor
+			// calls that "every page except the first"
+			doc.HFMode = HFModeExceptFirst
+		}
 		if cols := sect.first("cols"); cols != nil {
 			if n, err := strconv.Atoi(cols.attr("num")); err == nil && n > 1 {
 				pc.Columns = n

+ 21 - 6
src/mod/office/docx_writer.go

@@ -93,8 +93,11 @@ func BuildDocx(doc *Document) ([]byte, error) {
 		return err
 	}
 
-	hasHeader := strings.TrimSpace(doc.Header) != ""
-	hasFooter := strings.TrimSpace(doc.Footer) != "" || doc.PageNumbers
+	// hfMode "none" drops the text; "except-first" keeps the parts and lets
+	// Word blank page 1 through <w:titlePg/> (no "first" reference = empty)
+	hfText := doc.HFMode != HFModeNone
+	hasHeader := hfText && strings.TrimSpace(doc.Header) != ""
+	hasFooter := (hfText && strings.TrimSpace(doc.Footer) != "") || doc.PageNumbers
 
 	// [Content_Types].xml
 	var ct strings.Builder
@@ -151,7 +154,8 @@ func BuildDocx(doc *Document) ([]byte, error) {
 	}
 
 	// section properties (page setup)
-	sect := buildSectPr(doc.Page, headerRef, footerRef, b.usedDivider)
+	sect := buildSectPr(doc.Page, headerRef, footerRef, b.usedDivider,
+		doc.HFMode == HFModeExceptFirst)
 
 	docXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n" +
 		`<w:document ` + docxNs + `><w:body>` + b.body.String() + sect + `</w:body></w:document>`
@@ -170,7 +174,11 @@ func BuildDocx(doc *Document) ([]byte, error) {
 		}
 	}
 	if hasFooter {
-		if err := addFile("word/footer1.xml", buildHfPart("ftr", doc.Footer, doc.PageNumbers)); err != nil {
+		footerText := doc.Footer
+		if !hfText {
+			footerText = ""
+		}
+		if err := addFile("word/footer1.xml", buildHfPart("ftr", footerText, doc.PageNumbers)); err != nil {
 			return nil, err
 		}
 	}
@@ -877,7 +885,7 @@ func pgGeometry(pc *PageConf) string {
 		mmToTwips(mT), mmToTwips(mR), mmToTwips(mB), mmToTwips(mL))
 }
 
-func buildSectPr(pc *PageConf, headerRef, footerRef string, continuous bool) string {
+func buildSectPr(pc *PageConf, headerRef, footerRef string, continuous, titlePg bool) string {
 	typ := ""
 	if continuous {
 		// the columned body continues on the same page as the spanning
@@ -892,7 +900,14 @@ func buildSectPr(pc *PageConf, headerRef, footerRef string, continuous bool) str
 		}
 		cols = fmt.Sprintf(`<w:cols w:num="%d" w:space="%d"/>`, pc.Columns, mmToTwips(gap))
 	}
-	return `<w:sectPr>` + headerRef + footerRef + typ + pgGeometry(pc) + cols + `</w:sectPr>`
+	first := ""
+	if titlePg {
+		// "different first page" with no first-page reference: Word leaves
+		// page 1's header and footer empty
+		first = `<w:titlePg/>`
+	}
+	return `<w:sectPr>` + headerRef + footerRef + typ + pgGeometry(pc) + cols +
+		first + `</w:sectPr>`
 }
 
 // buildHfPart renders a header (root "hdr") or footer ("ftr") part

+ 4 - 0
src/mod/office/odt_reader.go

@@ -417,6 +417,10 @@ func odtReadPageStyles(root *onode, doc *Document) {
 			}
 			doc.Footer = strings.TrimSpace(txt)
 		}
+		// blank *-first parts are ODF's "different first page"
+		if mp.first("header-first") != nil || mp.first("footer-first") != nil {
+			doc.HFMode = HFModeExceptFirst
+		}
 	}
 }
 

+ 16 - 3
src/mod/office/odt_writer.go

@@ -460,11 +460,21 @@ func odtStylesXML(doc *Document) string {
 				doc.Page.Margins.Bottom, doc.Page.Margins.Left
 		}
 	}
+	// header/footer repeat on every page; hfMode "except-first" adds the
+	// empty *-first variants ODF uses for "different first page" (readers
+	// that ignore them simply show the text on page one as well)
+	header, footer := strings.TrimSpace(doc.Header), strings.TrimSpace(doc.Footer)
+	if doc.HFMode == HFModeNone {
+		header, footer = "", ""
+	}
+	firstBlank := doc.HFMode == HFModeExceptFirst
 	hf := ""
-	if strings.TrimSpace(doc.Header) != "" {
-		hf += `<style:header><text:p>` + xmlEscape(doc.Header) + `</text:p></style:header>`
+	if header != "" {
+		hf += `<style:header><text:p>` + xmlEscape(header) + `</text:p></style:header>`
+		if firstBlank {
+			hf += `<style:header-first><text:p/></style:header-first>`
+		}
 	}
-	footer := strings.TrimSpace(doc.Footer)
 	if footer != "" || doc.PageNumbers {
 		inner := xmlEscape(footer)
 		if doc.PageNumbers {
@@ -474,6 +484,9 @@ func odtStylesXML(doc *Document) string {
 			inner += `<text:page-number text:select-page="current"/>`
 		}
 		hf += `<style:footer><text:p>` + inner + `</text:p></style:footer>`
+		if firstBlank {
+			hf += `<style:footer-first><text:p/></style:footer-first>`
+		}
 	}
 	return `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
 		`<office:document-styles ` + odfNs + `>` +

+ 0 - 26
src/mod/office/pdf.go

@@ -104,26 +104,6 @@ func pdfOutput(pdf *fpdf.Fpdf) ([]byte, error) {
 	return buf.Bytes(), nil
 }
 
-// pdfSplitTr wraps text that has already been passed through the cp1252
-// translator. fpdf.SplitText indexes a 256-glyph width table by rune and
-// panics on runes outside it, so the translated bytes are promoted to
-// runes for splitting and demoted back to bytes afterwards.
-func pdfSplitTr(pdf *fpdf.Fpdf, trText string, w float64) []string {
-	rs := make([]rune, len(trText))
-	for i := 0; i < len(trText); i++ {
-		rs[i] = rune(trText[i])
-	}
-	var out []string
-	for _, ln := range pdf.SplitText(string(rs), w) {
-		b := make([]byte, 0, len(ln))
-		for _, r := range ln {
-			b = append(b, byte(r))
-		}
-		out = append(out, string(b))
-	}
-	return out
-}
-
 // pdfNbsp normalizes non-breaking / typographic spaces to plain spaces:
 // contenteditable HTML is full of &nbsp;, and fpdf only wraps lines at
 // real spaces, so leaving them in causes early / mid-word line breaks
@@ -140,9 +120,3 @@ func pdfTr(pdf *fpdf.Fpdf) func(string) string {
 		return tr(pdfNbsp.Replace(s))
 	}
 }
-
-// htmlPlainText flattens an HTML fragment into plain text lines (used for
-// table cells and similar single-block content)
-func htmlPlainText(h string) string {
-	return strings.Join(htmlToLines(h), "\n")
-}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 818 - 225
src/mod/office/pdf_doc.go


+ 367 - 2
src/mod/office/pdf_test.go

@@ -1,11 +1,16 @@
 package office
 
 import (
+	"archive/zip"
 	"bytes"
 	"compress/zlib"
 	"io"
+	"math"
 	"strings"
 	"testing"
+
+	"github.com/go-pdf/fpdf"
+	"golang.org/x/net/html"
 )
 
 // pdfStreamsText inflates every content stream in a PDF and returns the
@@ -82,8 +87,12 @@ func TestDocPdfTableCellImageAndBlocks(t *testing.T) {
 	if strings.Contains(text, "LapwingWaifu") {
 		t.Error("cell blocks mashed together without line breaks")
 	}
-	// bullets are cp1252-translated (byte 0x95) in the stream
-	for _, want := range []string{"Lapwing", "\x95 Waifu", "\x95 Cute"} {
+	// list markers hang in the indent, so they are their own text object
+	// (bullets are cp1252-translated to byte 0x95 in the stream)
+	if strings.Count(text, "\x95") < 2 {
+		t.Error("bullet markers missing from the cell list")
+	}
+	for _, want := range []string{"Lapwing", "Waifu", "Cute"} {
 		if !strings.Contains(text, want) {
 			t.Errorf("cell text missing %q", want)
 		}
@@ -131,6 +140,362 @@ func TestDocPdfHeaderFooter(t *testing.T) {
 	}
 }
 
+// layoutDoc lays out an HTML fragment on an A4 page with 20mm margins and
+// returns the top-level boxes, so the tests can assert on line counts and
+// block heights instead of guessing from the PDF stream
+func layoutDoc(t *testing.T, fragment string) (*docPdf, []pdfBox) {
+	t.Helper()
+	pdf := fpdf.NewCustom(&fpdf.InitType{OrientationStr: "P", UnitStr: "mm",
+		Size: fpdf.SizeType{Wd: 210, Ht: 297}})
+	pdf.SetMargins(20, 20, 20)
+	pdf.SetCellMargin(0)
+	pdf.AddPage()
+	d := &docPdf{pdf: pdf, tr: pdfTr(pdf), textW: 170, topY: 20, botY: 277}
+	root, err := html.Parse(strings.NewReader("<body>" + fragment + "</body>"))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	var body *html.Node
+	var find func(n *html.Node)
+	find = func(n *html.Node) {
+		if n.Type == html.ElementNode && n.Data == "body" {
+			body = n
+			return
+		}
+		for c := n.FirstChild; c != nil && body == nil; c = c.NextSibling {
+			find(c)
+		}
+	}
+	find(root)
+	return d, d.boxes(body, 20, 170, pdfSeg{sizePt: docBaseSizePt})
+}
+
+func boxHeight(b pdfBox) float64 {
+	h := 0.0
+	for _, it := range b.items {
+		h += it.h
+	}
+	return h
+}
+
+// Pasted HTML is hard-wrapped with real newlines; a browser collapses them
+// into spaces. Treating them as line breaks used to leave a huge blank gutter
+// on the right of every exported page and inflate the page count.
+func TestDocPdfCollapsesSourceNewlines(t *testing.T) {
+	words := strings.Repeat("lorem ipsum dolor sit amet consectetur ", 6)
+	hard := strings.ReplaceAll(words, " ", "\n") // as pasted from a web page
+	_, wrapped := layoutDoc(t, "<p>"+words+"</p>")
+	_, pasted := layoutDoc(t, "<p>"+hard+"</p>")
+	if len(wrapped) != 1 || len(pasted) != 1 {
+		t.Fatalf("expected one block each, got %d and %d", len(wrapped), len(pasted))
+	}
+	if got, want := boxHeight(pasted[0]), boxHeight(wrapped[0]); got != want {
+		t.Errorf("hard-wrapped source laid out differently: %.2fmm vs %.2fmm", got, want)
+	}
+	// 36 words at 11pt fill far fewer than 36 lines once collapsed
+	if n := len(wrapped[0].items); n > 6 {
+		t.Errorf("text wrapped too early: %d lines for %d words", n, 36)
+	}
+}
+
+// Lines must run to the right text edge, not stop a wide margin short of it
+func TestDocPdfUsesFullTextWidth(t *testing.T) {
+	para := "<p>" + strings.Repeat("alpha beta gamma delta ", 8) + "</p>"
+	d, _ := layoutDoc(t, para)
+	lines := d.paraLines(mustFirstElement(t, para), 170,
+		pdfSeg{sizePt: docBaseSizePt}, docLineFactor)
+	if len(lines) < 2 {
+		t.Fatalf("expected the text to wrap, got %d line(s)", len(lines))
+	}
+	for i, ln := range lines[:len(lines)-1] { // the last line is short by nature
+		if ln.w < 160 {
+			t.Errorf("line %d only uses %.1fmm of the 170mm text width", i, ln.w)
+		}
+		if ln.w > 170 {
+			t.Errorf("line %d overflows the text width: %.1fmm", i, ln.w)
+		}
+	}
+}
+
+// Browser rules for near-empty blocks, which decide where pages break
+func TestDocPdfEmptyBlockHeights(t *testing.T) {
+	line := docBaseSizePt * docLineFactor * 25.4 / 72.0
+	cases := []struct {
+		name string
+		html string
+		want float64
+	}{
+		{"empty paragraph", "<p></p>", 0},
+		{"whitespace only", "<p>\n  </p>", 0},
+		{"placeholder br", "<p><br></p>", line},
+		{"trailing br", "<p>text<br></p>", line},
+		{"real break", "<p>a<br>b</p>", 2 * line},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, boxes := layoutDoc(t, tc.html)
+			got := 0.0
+			for _, b := range boxes {
+				got += boxHeight(b)
+			}
+			if math.Abs(got-tc.want) > 0.01 {
+				t.Errorf("height = %.2fmm, want %.2fmm", got, tc.want)
+			}
+		})
+	}
+}
+
+// The exported PDF only paginates like the editor's page preview if every
+// block is exactly as tall as the browser makes it. The wanted values were
+// measured in Chrome with docs.css applied (margin box of the whole flow);
+// if docs.css changes, re-measure and update both sides.
+func TestDocPdfBlockHeightsMatchEditorCSS(t *testing.T) {
+	cases := []struct {
+		name string
+		html string
+		want float64 // mm, as measured in the browser
+	}{
+		{"plain paragraph", "<p>hello</p>", 5.82},
+		{"heading", "<h1>Heading</h1>", 15.87},
+		{"heading in a wrapper", "<div><h1>Heading</h1></div>", 15.87},
+		{"collapsed margins", "<p>a</p><h1>b</h1>", 21.70},
+		{"two headings", "<h1>a</h1><h2>b</h2>", 27.78},
+		{"table cell blocks", "<table class=\"of-table\"><tbody><tr>" +
+			"<td><h1>Lapwing</h1><p>x</p></td></tr></tbody></table>", 29.90},
+		{"table plain cells", "<table class=\"of-table\"><tbody><tr>" +
+			"<td>plain</td><td>cell</td></tr></tbody></table>", 14.02},
+		{"blockquote", "<blockquote>quoted line</blockquote>", 12.96},
+		{"bulleted list", "<ul><li>one</li><li>two</li><li>three</li></ul>", 23.02},
+		{"checklist", "<ul class=\"of-checklist\"><li>one</li>" +
+			"<li class=\"checked\">two</li></ul>", 16.93},
+		{"numbered list", "<ol><li>one</li><li>two</li></ol>", 16.67},
+		{"preformatted", "<pre>code line\ncode two</pre>", 21.70},
+		{"rule between paragraphs", "<p>a</p><hr><p>b</p>", 21.70},
+		{"mixed font sizes", "<p><span style=\"font-size:16pt\">big</span> small</p>", 8.47},
+		{"wrapped paragraph", "<p>" + strings.Repeat("alpha beta ", 39) + "</p>", 29.10},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			_, boxes := layoutDoc(t, tc.html)
+			got, pending := 0.0, 0.0
+			for i, b := range boxes {
+				gap := b.mt
+				if i > 0 {
+					gap = math.Max(pending, b.mt)
+				}
+				got += gap + boxHeight(b)
+				pending = b.mb
+			}
+			got += pending
+			if math.Abs(got-tc.want) > 0.2 {
+				t.Errorf("block height = %.2fmm, browser lays it out at %.2fmm",
+					got, tc.want)
+			}
+		})
+	}
+}
+
+// A block that does not fit on the rest of a page moves to the next one as a
+// whole - the same rule the editor's page preview uses - so both agree on the
+// page count
+func TestDocPdfKeepsBlocksWhole(t *testing.T) {
+	// 257mm of usable height / 5.82mm per line = 44 lines per page
+	filler := strings.Repeat("<p>filler line</p>", 42)
+	para := "<p>" + strings.Repeat("word ", 60) + "</p>" // ~4 lines, cannot fit
+	data, err := BuildDocPdf(&Document{HTML: filler + para,
+		Page: &PageConf{Size: "A4", Orientation: "portrait",
+			Margins: &MarginsMM{Top: 20, Right: 20, Bottom: 20, Left: 20}}})
+	if err != nil {
+		t.Fatalf("BuildDocPdf: %v", err)
+	}
+	if got := pdfPageCount(data); got != 2 {
+		t.Fatalf("page count = %d, want 2", got)
+	}
+	// the paragraph must start on page 2, not straddle the break
+	pages := strings.SplitN(pdfStreamsText(t, data), "(filler line)", 43)
+	if strings.Contains(pages[len(pages)-1], "(word") == false {
+		t.Error("paragraph did not move to the next page in one piece")
+	}
+}
+
+func pdfPageCount(data []byte) int {
+	s := string(data)
+	return strings.Count(s, "/Type /Page\n") + strings.Count(s, "/Type /Page ")
+}
+
+func mustFirstElement(t *testing.T, fragment string) *html.Node {
+	t.Helper()
+	root, err := html.Parse(strings.NewReader("<body>" + fragment + "</body>"))
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	var found *html.Node
+	var walk func(n *html.Node)
+	walk = func(n *html.Node) {
+		if found != nil {
+			return
+		}
+		if n.Type == html.ElementNode && n.Data == "p" {
+			found = n
+			return
+		}
+		for c := n.FirstChild; c != nil; c = c.NextSibling {
+			walk(c)
+		}
+	}
+	walk(root)
+	if found == nil {
+		t.Fatal("no <p> in fragment")
+	}
+	return found
+}
+
+// unzipParts reads every text part of a docx/odt package
+func unzipParts(t *testing.T, data []byte) map[string]string {
+	t.Helper()
+	zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+	if err != nil {
+		t.Fatalf("output is not a valid zip: %v", err)
+	}
+	out := map[string]string{}
+	for _, f := range zr.File {
+		rc, err := f.Open()
+		if err != nil {
+			continue
+		}
+		b, _ := io.ReadAll(rc)
+		rc.Close()
+		out[f.Name] = string(b)
+	}
+	return out
+}
+
+// Header and footer text repeat on every page; hfMode decides where they are
+// suppressed (and a suppressed first page suppresses its page number too)
+func TestDocPdfHeaderFooterModes(t *testing.T) {
+	// three pages of content
+	body := strings.Repeat("<p>line</p>", 100)
+	cases := []struct {
+		mode            string
+		wantPerPage     []int // page -> expected count of the header text
+		wantPageNumbers []string
+	}{
+		{HFModeAll, []int{1, 1, 1}, []string{"1", "2", "3"}},
+		{HFModeExceptFirst, []int{0, 1, 1}, []string{"2", "3"}},
+		{HFModeNone, []int{0, 0, 0}, []string{"1", "2", "3"}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.mode, func(t *testing.T) {
+			data, err := BuildDocPdf(&Document{HTML: body, Header: "ACME Corp",
+				Footer: "Confidential", PageNumbers: true, HFMode: tc.mode,
+				Page: &PageConf{Size: "A4", Orientation: "portrait",
+					Margins: &MarginsMM{Top: 20, Right: 20, Bottom: 20, Left: 20}}})
+			if err != nil {
+				t.Fatalf("BuildDocPdf: %v", err)
+			}
+			if got := pdfPageCount(data); got != 3 {
+				t.Fatalf("page count = %d, want 3", got)
+			}
+			text := pdfStreamsText(t, data)
+			want := 0
+			for _, n := range tc.wantPerPage {
+				want += n
+			}
+			if got := strings.Count(text, "ACME Corp"); got != want {
+				t.Errorf("header drawn %d time(s), want %d", got, want)
+			}
+			if got := strings.Count(text, "Confidential"); got != want {
+				t.Errorf("footer drawn %d time(s), want %d", got, want)
+			}
+			for _, n := range tc.wantPageNumbers {
+				marker := "Confidential - " + n
+				if tc.mode == HFModeNone {
+					marker = "(" + n + ")"
+				}
+				if !strings.Contains(text, marker) {
+					t.Errorf("page counter %q missing", marker)
+				}
+			}
+			if tc.mode == HFModeExceptFirst && strings.Contains(text, "(1) Tj") {
+				t.Error("page 1 must not carry a page number when it has no footer")
+			}
+		})
+	}
+}
+
+func TestDocxHeaderFooterModes(t *testing.T) {
+	doc := &Document{HTML: "<p>x</p>", Header: "Head", Footer: "Foot",
+		PageNumbers: true, HFMode: HFModeExceptFirst}
+	data, err := BuildDocx(doc)
+	if err != nil {
+		t.Fatalf("BuildDocx: %v", err)
+	}
+	parts := unzipParts(t, data)
+	if !strings.Contains(parts["word/document.xml"], "<w:titlePg/>") {
+		t.Error("except-first must set <w:titlePg/> so Word blanks page 1")
+	}
+	if !strings.Contains(parts["word/header1.xml"], "Head") {
+		t.Error("header part missing")
+	}
+	// round trip: the reader recognises it again
+	back, err := ParseDocx(data)
+	if err != nil {
+		t.Fatalf("ParseDocx: %v", err)
+	}
+	if back.HFMode != HFModeExceptFirst {
+		t.Errorf("hfMode round trip = %q, want %q", back.HFMode, HFModeExceptFirst)
+	}
+
+	doc.HFMode = HFModeNone
+	data, err = BuildDocx(doc)
+	if err != nil {
+		t.Fatalf("BuildDocx: %v", err)
+	}
+	parts = unzipParts(t, data)
+	if _, ok := parts["word/header1.xml"]; ok {
+		t.Error("mode none must not write a header part")
+	}
+	// the page counter is its own setting and survives
+	if !strings.Contains(parts["word/footer1.xml"], "PAGE") {
+		t.Error("page number field lost")
+	}
+	if strings.Contains(parts["word/footer1.xml"], "Foot") {
+		t.Error("mode none must drop the footer text")
+	}
+}
+
+func TestOdtHeaderFooterModes(t *testing.T) {
+	doc := &Document{HTML: "<p>x</p>", Header: "Head", Footer: "Foot",
+		HFMode: HFModeExceptFirst}
+	data, err := BuildOdt(doc)
+	if err != nil {
+		t.Fatalf("BuildOdt: %v", err)
+	}
+	styles := unzipParts(t, data)["styles.xml"]
+	for _, want := range []string{"<style:header>", "<style:header-first>",
+		"<style:footer-first>"} {
+		if !strings.Contains(styles, want) {
+			t.Errorf("styles.xml missing %s", want)
+		}
+	}
+	back, err := ParseOdt(data)
+	if err != nil {
+		t.Fatalf("ParseOdt: %v", err)
+	}
+	if back.HFMode != HFModeExceptFirst {
+		t.Errorf("hfMode round trip = %q, want %q", back.HFMode, HFModeExceptFirst)
+	}
+
+	doc.HFMode = HFModeNone
+	data, err = BuildOdt(doc)
+	if err != nil {
+		t.Fatalf("BuildOdt: %v", err)
+	}
+	if styles := unzipParts(t, data)["styles.xml"]; strings.Contains(styles, "Head") {
+		t.Error("mode none must not write header text")
+	}
+}
+
 func TestSheetPdf(t *testing.T) {
 	m := &SheetPrintModel{Sheets: []*SheetPrintSheet{
 		{Name: "Budget", ColW: []float64{120, 80},

+ 62 - 10
src/web/Office/README.md

@@ -67,8 +67,11 @@ Go structs are the source of truth — they mirror the JS exactly:
 
 - **Docs** (`document`): [`docx.go`](../../mod/office/docx.go) —
   `{html, page{size, orientation, margins(mm), columns, colGap}, header,
-  footer, pageNumbers, comments, trackChanges}`. `html` is a sanitized
-  contenteditable subset (see `sanitizeHtml` in `docs.js`).
+  footer, hfMode, pageNumbers, comments, trackChanges}`. `html` is a
+  sanitized contenteditable subset (see `sanitizeHtml` in `docs.js`).
+  `hfMode` (`all` | `except-first` | `none`, Format > Header & footer)
+  says which pages repeat the header/footer text; empty means `all`, so
+  documents written before the setting existed keep their behaviour.
 - **Sheets** (`spreadsheet`): [`xlsx.go`](../../mod/office/xlsx.go) —
   `{sheets[{name, cells{"A1":{v,s,n}}, colW, rowH, merges, freeze,
   charts, filter}], active}`. Cell `v` is the raw input (`=`-prefix =
@@ -110,6 +113,39 @@ body before posting:
   (`rasterizeEmojiForPdf` in `docs.js`) because PDF core fonts are
   Latin-1 and have no emoji glyphs.
 
+### Header / footer
+
+The header and footer are one editable pair per **simulated** page: the
+editor keeps a copy in every sheet's margin band (`layoutHeaderFooters`
+in `docs.js`), all of them editable and mirroring each other, so the text
+can be changed from any page. They are absolutely positioned on purpose —
+an in-flow header ate page-one content and made the preview disagree with
+the export about where the first page ends.
+
+Pagination is measured from the live DOM, so it can only be right once the
+DOM has its final size: a document opened from disk paginates while its
+pictures are still decoding (a fresh `<img>` measures **zero** tall until
+its `load` fires), which used to leave the bands and the automatic page
+breaks positioned for a much shorter document. `watchContentSize()` in
+`docs.js` re-runs `updatePageGuides()` whenever the flow changes height on
+its own — image `load` (captured on `#editor`, since `load` does not
+bubble), `document.fonts.ready`, and a `ResizeObserver`. Keep that hook
+alive when touching the boot path.
+
+`hfMode` maps onto each format's own mechanism:
+
+| mode | preview | PDF | DOCX | ODT |
+|---|---|---|---|---|
+| `all` | band on every sheet | header/footer func on every page | `header1.xml` / `footer1.xml` | `style:header` / `style:footer` |
+| `except-first` | page one's band hidden | `hfOnPage()` skips page 1 | `<w:titlePg/>` and no first-page part | empty `style:header-first` / `style:footer-first` |
+| `none` | no bands | no header/footer text | no parts written | no header/footer elements |
+
+The page counter (`pageNumbers`) stays its own page-setup option, except
+that a suppressed first page suppresses its number too — the same thing
+Word's `titlePg` does. Browser **printing** repeats one `position: fixed`
+pair on every sheet (print engines cannot skip page one); PDF export is
+the path that honours every mode exactly.
+
 ### Format notes (hard-won lessons — don't re-learn these)
 
 - **DOCX pagination** ([`docx_writer.go`](../../mod/office/docx_writer.go)):
@@ -134,18 +170,34 @@ body before posting:
   [`pdf_sheet.go`](../../mod/office/pdf_sheet.go) /
   [`pdf_slides.go`](../../mod/office/pdf_slides.go)): built on
   `github.com/go-pdf/fpdf` (MIT). Real selectable text, not screenshots.
-  Gotchas encoded in `pdf.go`:
+  Gotchas encoded in `pdf.go` / `pdf_doc.go`:
   - Core fonts are **cp1252** — all text goes through `pdfTr()`, which
     also normalizes `&nbsp;`/thin spaces to plain spaces (fpdf only wraps
     lines at real spaces; contenteditable HTML is full of nbsp and the
     lines wrapped comically early before this).
-  - **Never call `fpdf.SplitText` on translated text directly** — it
-    indexes a 256-glyph table by rune and panics on multi-byte UTF-8.
-    Use `pdfSplitTr()`.
-  - Text highlight is drawn word-by-word as filled cells
-    (`writeHighlighted`) because fpdf has no text background.
-  - Small images (≤ 8mm tall, i.e. rasterized emoji) flow inline with
-    text; larger ones are block images (`inlineImage`).
+  - **Docs does its own line breaking and pagination** — `pdf_doc.go` is
+    a small CSS-shaped layout engine (`boxes` → `pdfBox` → `pdfItem`),
+    not a stream of `fpdf.Write` calls. It exists so the export
+    paginates *exactly* like the editor's page preview:
+    - HTML whitespace is collapsed like a browser collapses it
+      (`collapseWS`). Pasted markup is hard-wrapped with real newlines;
+      fpdf's `Write` treats those as forced breaks, which used to leave
+      a wide blank gutter down the right of every page and inflate the
+      page count.
+    - `SetCellMargin(0)` + `SetAutoPageBreak(false)`: the browser wraps
+      at the content edge, and a block that would cross the bottom
+      margin moves to the next page **whole** (`place`), the same rule
+      `updatePageGuides()` uses in `docs.js`.
+    - Block margins collapse (`flow`), empty blocks follow the browser's
+      rules (`<p></p>` = 0 tall, `<p><br></p>` = one line, a trailing
+      `<br>` adds nothing), and `#editor img { height: auto }` means the
+      aspect ratio wins over a `height` attribute.
+    - Any metric change in `docs.css` (font size, line-height, block
+      margins, cell padding, list indent) must be mirrored by the
+      constants at the top of `pdf_doc.go`, or the two page counts drift
+      apart.
+    - Multi-column page layout (`page.columns`) is **not** implemented in
+      the PDF exporter — those documents export as a single column.
   - Embedding a Unicode font was deliberately rejected (megabytes on the
     binary); CJK text transliterates/degrades. That's the top candidate
     if someone asks for CJK PDF export.

+ 15 - 6
src/web/Office/docs/docs.css

@@ -36,9 +36,16 @@ body.dark #page {
     box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 22px rgba(0, 0, 0, 0.6);
 }
 
-/* ============ Header / footer areas ============ */
+/* ============ Header / footer areas ============
+   One editable copy per simulated page, parked in that sheet's top / bottom
+   margin band - docs.js (layoutHeaderFooters) creates, positions and keeps
+   them in sync; which pages get one is the hfMode setting. They are taken
+   out of the flow on purpose: the header must not eat page-one content the
+   way an in-flow header did, or the preview and the exported PDF disagree
+   about where the first page ends. */
 .doc-hf {
-    flex: 0 0 auto;
+    position: absolute;
+    box-sizing: border-box;
     min-height: 1.3em;
     outline: none;
     font-family: Arial, Helvetica, sans-serif;
@@ -47,21 +54,23 @@ body.dark #page {
     padding: 2px 0;
     border-bottom: 1px dashed transparent;
     cursor: text;
+    z-index: 6;
 }
-#docFooter {
+.doc-hf.doc-hf-footer {
     border-bottom: none;
     border-top: 1px dashed transparent;
 }
 .doc-hf:hover, .doc-hf:focus {
     border-color: #c9cdd3;
 }
-.doc-hf:empty::before {
+/* only the first visible copy nags: one placeholder per document, not one
+   per page */
+.doc-hf.doc-hf-lead:empty::before {
     content: attr(data-placeholder);
     color: #c2c6cc;
     pointer-events: none;
 }
-#docHeader { margin-bottom: 14px; }
-#docFooter { margin-top: 14px; }
+.doc-hf[hidden] { display: none; }
 
 /* ============ Editor content ============ */
 #editor {

+ 224 - 25
src/web/Office/docs/docs.js

@@ -13,8 +13,9 @@
             orientation: "portrait" | "landscape",
             margins: { top: 20, right: 20, bottom: 20, left: 20 }   // millimetres
         },
-        header: "plain text shown at the top of the paper",
-        footer: "plain text shown at the bottom of the paper",
+        header: "plain text repeated in every page's top margin",
+        footer: "plain text repeated in every page's bottom margin",
+        hfMode: "all" | "except-first" | "none",   // which pages show them
         pageNumbers: false,     // print page numbers (best-effort @page margin box)
         comments: [ { id, text, at, resolved } ],   // review comments; anchored
                                 // in html as <span class="doc-cmt" data-cid>
@@ -49,6 +50,8 @@
     ];
     var FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72];
     var LINE_SPACINGS = ["1", "1.15", "1.5", "2"];
+    // which pages carry the header / footer text
+    var HF_MODES = ["all", "except-first", "none"];
     var PARA_STYLES = [
         { v: "p",     label: "Normal text" },
         { v: "title", label: "Title" },
@@ -105,6 +108,7 @@
     var stateTimer = null;
     var $fontSel, $sizeSel, $styleSel;
     var findState = { hits: [], cur: -1 };
+    var hfClones = [];             // header/footer copies for pages 2..n
     var comments = [];             // review comments [{id,text,at,resolved}]
     var suggesting = false;        // track-changes ("suggest edits") mode
 
@@ -114,6 +118,7 @@
             orientation: "portrait",
             margins: { top: 20, right: 20, bottom: 20, left: 20 },
             pageNumbers: false,
+            hfMode: "all",     // header/footer on: all | except-first | none
             columns: 1,        // 1-3 text columns (2 = IEEE-style)
             colGap: 8          // gap between columns, mm
         };
@@ -158,7 +163,7 @@
     }
     function inHeaderFooter() {
         var ae = document.activeElement;
-        return ae === headerEl || ae === footerEl;
+        return !!(ae && ae.classList && ae.classList.contains("doc-hf"));
     }
     function getSelectedBlocks() {
         var out = [];
@@ -263,6 +268,7 @@
             },
             header: hfText(headerEl),
             footer: hfText(footerEl),
+            hfMode: pageConf.hfMode,
             pageNumbers: !!pageConf.pageNumbers,
             comments: JSON.parse(JSON.stringify(comments)),
             trackChanges: suggesting
@@ -287,6 +293,7 @@
         pageConf.columns = Math.min(3, Math.max(1, Math.round(num(p.columns, 1))));
         pageConf.colGap = Math.min(30, Math.max(2, num(p.colGap, 8)));
         pageConf.pageNumbers = !!b.pageNumbers;
+        pageConf.hfMode = (HF_MODES.indexOf(b.hfMode) >= 0) ? b.hfMode : "all";
         comments = Array.isArray(b.comments) ?
             JSON.parse(JSON.stringify(b.comments)) : [];
         suggesting = !!b.trackChanges;
@@ -1439,6 +1446,19 @@
         css += "}\n";
         css += "@media print {\n";
         css += "    #page { width: auto !important; min-height: 0 !important; padding: 0 !important; }\n";
+        /* the screen bands are absolutely placed per simulated page; when the
+           printer paginates for real, one fixed pair repeats on every sheet
+           (first-page suppression is a PDF-export feature - print engines
+           cannot skip it) */
+        if (pageConf.hfMode === "none") {
+            css += "    .doc-hf { display: none !important; }\n";
+        } else {
+            css += "    .doc-hf { position: fixed !important; left: 0 !important; right: 0 !important; }\n";
+            css += "    #docHeader { top: 0 !important; }\n";
+            css += "    #docFooter { top: auto !important; bottom: 0 !important; }\n";
+            css += "    .doc-hf:not(#docHeader):not(#docFooter) { display: none !important; }\n";
+            css += "    .doc-hf[hidden] { display: block !important; }\n";
+        }
         css += "}\n";
         var tag = document.getElementById("pagePrintStyle");
         if (tag) tag.textContent = css;
@@ -1485,8 +1505,10 @@
         el.style.height = "0px";
         var top = offsetTopInPage(el);
         // a break can sit several auto-pages further down (guarded above,
-        // but keep the math safe)
-        while (top >= pageStart + innerHpx) pageStart += innerHpx;
+        // but keep the math safe). Strictly greater: a break that lands
+        // exactly ON the boundary belongs to this page - counting it as the
+        // next one used to insert a whole blank sheet.
+        while (top > pageStart + innerHpx + 0.5) pageStart += innerHpx;
         var rest = Math.max(0, pageStart + innerHpx - top);
         el.style.height = (rest + mBotPx + PAGE_GAP_PX + topPx) + "px";
         var gap = el.querySelector(".doc-pb-gap");
@@ -1513,6 +1535,157 @@
         }
         return null;
     }
+    /* ---------- header / footer bands ----------
+       The editable header/footer is repeated once per simulated page, parked
+       in that sheet's margin band. Every copy is editable and they mirror
+       each other, so the text can be changed from any page. pageConf.hfMode
+       decides which pages get one, and the exporters read the same setting. */
+    var HF_GAP_PX = 14;      // space between a band and the text area
+
+    function hfPairs() {
+        return [{ header: headerEl, footer: footerEl }].concat(hfClones);
+    }
+    function hfShownOn(pageIndex) {   // pageIndex is 0-based
+        if (pageConf.hfMode === "none") return false;
+        if (pageConf.hfMode === "except-first" && pageIndex === 0) return false;
+        return true;
+    }
+    // mirror one band's text into every other copy (never into the one being
+    // typed in, so the caret survives)
+    function syncHfText(kind, text, except) {
+        hfPairs().forEach(function (pair) {
+            var el = pair[kind];
+            if (el !== except && el.textContent !== text) el.textContent = text;
+        });
+    }
+    function bindHfEvents(el) {
+        el.addEventListener("input", function () {
+            syncHfText(el.getAttribute("data-hf"), el.textContent, el);
+            OfficeApp.markDirty();
+            undo.pushDebounced(snapshot, 600);
+        });
+        el.addEventListener("keydown", function (e) {
+            if (e.key === "Enter") e.preventDefault();   // single line only
+        });
+        el.addEventListener("paste", function (e) {
+            e.preventDefault();
+            var t = e.clipboardData ? e.clipboardData.getData("text/plain") : "";
+            if (t) {
+                try { document.execCommand("insertText", false, t.replace(/\s*\n+\s*/g, " ")); }
+                catch (err) { }
+            }
+        });
+    }
+    function makeHfCopy(kind) {
+        var src = (kind === "header") ? headerEl : footerEl;
+        var el = document.createElement("div");
+        el.className = "doc-hf doc-hf-" + kind;
+        el.setAttribute("contenteditable", "true");
+        el.setAttribute("spellcheck", "false");
+        el.setAttribute("data-hf", kind);
+        el.setAttribute("data-placeholder", src.getAttribute("data-placeholder") || "");
+        el.textContent = src.textContent;
+        bindHfEvents(el);
+        pageEl.appendChild(el);
+        return el;
+    }
+    function parkHeaderFooters() {
+        hfPairs().forEach(function (pair) {
+            ["header", "footer"].forEach(function (kind) {
+                pair[kind].style.top = "0px";
+                pair[kind].hidden = true;
+            });
+        });
+    }
+    function layoutHeaderFooters(pageTops, innerHpx, topPx, mL, mR, mB) {
+        // one copy per page after the first (page one uses the originals)
+        while (hfClones.length < pageTops.length - 1) {
+            hfClones.push({ header: makeHfCopy("header"), footer: makeHfCopy("footer") });
+        }
+        while (hfClones.length > pageTops.length - 1) {
+            var drop = hfClones.pop();
+            if (drop.header.parentNode) drop.header.parentNode.removeChild(drop.header);
+            if (drop.footer.parentNode) drop.footer.parentNode.removeChild(drop.footer);
+        }
+        var text = { header: headerEl.textContent, footer: footerEl.textContent };
+        var pageHpx = topPx + innerHpx + mB;
+        var lead = true;
+        hfPairs().forEach(function (pair, i) {
+            var show = hfShownOn(i);
+            var sheetTop = pageTops[i] - topPx;
+            ["header", "footer"].forEach(function (kind) {
+                var el = pair[kind];
+                el.hidden = !show;
+                el.classList.toggle("doc-hf-lead", show && lead);
+                if (!show) return;
+                if (el !== document.activeElement && el.textContent !== text[kind]) {
+                    el.textContent = text[kind];
+                }
+                el.style.left = mL + "px";
+                el.style.right = mR + "px";
+                var h = el.offsetHeight;
+                var top;
+                if (kind === "header") {
+                    top = Math.max(sheetTop + 2, pageTops[i] - HF_GAP_PX - h);
+                } else {
+                    top = Math.min(sheetTop + pageHpx - 2 - h,
+                        pageTops[i] + innerHpx + HF_GAP_PX);
+                }
+                el.style.top = Math.round(top) + "px";
+            });
+            if (show) lead = false;
+        });
+    }
+    /* ---------- deferred relayout ----------
+       Pictures in a document that was just opened are still decoding while
+       loadBody() paginates: they measure zero tall, so the page tops - and
+       with them the header/footer bands and the automatic breaks - come out
+       for a much shorter document than the one that ends up on screen.
+       Anything that changes the flow height without an edit (an image
+       finishing, a web font, a window resize) re-runs the pagination. */
+    var relayoutTimer = null;
+    var relayoutH = -1;        // editor height the current layout was made for
+    var relayouting = false;
+    function scheduleRelayout() {
+        clearTimeout(relayoutTimer);
+        relayoutTimer = setTimeout(function () {
+            relayouting = true;
+            updatePageGuides();
+            relayouting = false;
+        }, 80);
+    }
+    function watchContentSize() {
+        // load does not bubble, but it is seen in the capture phase
+        var onLoad = function (e) {
+            if (e.target && e.target.tagName === "IMG") scheduleRelayout();
+        };
+        editor.addEventListener("load", onLoad, true);
+        editor.addEventListener("error", onLoad, true);
+        if (window.ResizeObserver) {
+            new ResizeObserver(function () {
+                if (relayouting) return;
+                if (Math.abs(editor.offsetHeight - relayoutH) <= 1) return;
+                scheduleRelayout();
+            }).observe(editor);
+        }
+        if (document.fonts && document.fonts.ready) {
+            document.fonts.ready.then(scheduleRelayout).catch(function () { });
+        }
+    }
+
+    function setHfMode(mode) {
+        if (HF_MODES.indexOf(mode) < 0) return;
+        pageConf.hfMode = mode;
+        updatePrintStyle();
+        updatePageGuides();
+        afterEdit(true);
+        OfficeApp.setStatus({
+            "all": "Header and footer shown on every page",
+            "except-first": "Header and footer shown on every page except the first",
+            "none": "Header and footer turned off"
+        }[mode]);
+    }
+
     function updatePageGuides() {
         var holder = document.getElementById("pageGuides");
         if (!holder) {
@@ -1530,6 +1703,10 @@
         var mL = m.left * MM_PX, mR = m.right * MM_PX, mB = m.bottom * MM_PX;
         var multiCol = pageConf.columns > 1;
 
+        // park the header/footer bands first: a copy still sitting at the
+        // bottom of a longer previous layout would inflate scrollHeight and
+        // conjure a phantom page below
+        parkHeaderFooters();
         // relayout from the natural flow (self-heal breaks that arrived
         // without their gap markup, e.g. via raw HTML)
         removeAutoBreaks(editor);
@@ -1540,6 +1717,7 @@
 
         var pages = 1;
         var pageStart = topPx;
+        var pageTops = [topPx];   // content-top of every sheet, for the bands
         var bi = 0;
         var guard = 0;
         while (guard++ < 400) {
@@ -1547,6 +1725,7 @@
             // (a) an explicit break on this page ends it early
             if (bi < breaks.length && offsetTopInPage(breaks[bi]) <= boundary + 0.5) {
                 pageStart = stretchBreak(breaks[bi++], pageStart, innerHpx, topPx, mL, mR, mB);
+                pageTops.push(pageStart);
                 pages++;
                 continue;
             }
@@ -1561,6 +1740,7 @@
                     // the boundary lands just before an explicit break - the
                     // break itself owns this cut
                     pageStart = stretchBreak(breaks[bi++], pageStart, innerHpx, topPx, mL, mR, mB);
+                    pageTops.push(pageStart);
                     pages++;
                     continue;
                 }
@@ -1571,6 +1751,7 @@
                     sp.innerHTML = gapMarkup();
                     block.parentNode.insertBefore(sp, block);
                     pageStart = stretchBreak(sp, pageStart, innerHpx, topPx, mL, mR, mB);
+                    pageTops.push(pageStart);
                     pages++;
                     continue;
                 }
@@ -1584,10 +1765,13 @@
                 holder.appendChild(g);
             }
             pageStart = boundary;
+            pageTops.push(pageStart);
             pages++;
         }
         // the last sheet always shows at full page height
         pageEl.style.minHeight = (pageStart + innerHpx + mB) + "px";
+        layoutHeaderFooters(pageTops, innerHpx, topPx, mL, mR, mB);
+        relayoutH = editor.offsetHeight;   // what this layout was made for
         return pages;
     }
     /* ---------- explicit page breaks ---------- */
@@ -2285,9 +2469,12 @@
         var title = exportBaseName();
         var out = "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n" +
             "<title>" + esc(title) + "</title>\n<style>" + EXPORT_CSS + "</style>\n</head>\n<body>\n";
-        if (body.header) out += '<div class="hf">' + esc(body.header) + "</div>\n";
+        // a web page has no pages to repeat them on: one copy top and bottom,
+        // and nothing at all when the header/footer are turned off
+        var hf = body.hfMode !== "none";
+        if (hf && body.header) out += '<div class="hf">' + esc(body.header) + "</div>\n";
         out += resolvedHtml() + "\n";
-        if (body.footer) out += '<div class="hf">' + esc(body.footer) + "</div>\n";
+        if (hf && body.footer) out += '<div class="hf">' + esc(body.footer) + "</div>\n";
         out += "</body>\n</html>\n";
         downloadFile(title + ".html", "text/html", out);
     }
@@ -2999,24 +3186,14 @@
         workspaceEl.addEventListener("scroll", positionImgHandle);
         window.addEventListener("resize", positionImgHandle);
 
-        // header / footer: plain single-line text only
-        [headerEl, footerEl].forEach(function (el) {
-            el.addEventListener("input", function () {
-                OfficeApp.markDirty();
-                undo.pushDebounced(snapshot, 600);
-            });
-            el.addEventListener("keydown", function (e) {
-                if (e.key === "Enter") e.preventDefault();
-            });
-            el.addEventListener("paste", function (e) {
-                e.preventDefault();
-                var t = e.clipboardData ? e.clipboardData.getData("text/plain") : "";
-                if (t) {
-                    try { document.execCommand("insertText", false, t.replace(/\s*\n+\s*/g, " ")); }
-                    catch (err) { }
-                }
-            });
-        });
+        // header / footer: plain single-line text only, kept in sync across
+        // every page's copy (bindHfEvents also runs for the copies)
+        bindHfEvents(headerEl);
+        bindHfEvents(footerEl);
+
+        // re-paginate when the flow grows on its own (pictures decoding after
+        // a document is opened, web fonts, a resize)
+        watchContentSize();
     }
 
     /* ================= shortcuts ================= */
@@ -3155,6 +3332,28 @@
                                     });
                                 }
                             },
+                            {
+                                label: "Header & footer", icon: "window minimize outline",
+                                sub: function () {
+                                    return [
+                                        {
+                                            label: "Same on all pages",
+                                            checked: pageConf.hfMode === "all",
+                                            action: function () { setHfMode("all"); }
+                                        },
+                                        {
+                                            label: "All pages except the first",
+                                            checked: pageConf.hfMode === "except-first",
+                                            action: function () { setHfMode("except-first"); }
+                                        },
+                                        {
+                                            label: "None",
+                                            checked: pageConf.hfMode === "none",
+                                            action: function () { setHfMode("none"); }
+                                        }
+                                    ];
+                                }
+                            },
                             { sep: true },
                             { label: "Bulleted list", icon: "list ul", key: "Ctrl+Shift+8", action: function () { exec("insertUnorderedList"); } },
                             { label: "Numbered list", icon: "list ol", key: "Ctrl+Shift+7", action: function () { exec("insertOrderedList"); } },

+ 8 - 4
src/web/Office/docs/index.html

@@ -29,11 +29,15 @@
     <div class="of-workspace" id="workspace">
         <div id="pageWrap">
             <div id="page">
-                <div id="docHeader" class="doc-hf" contenteditable="true"
-                     data-placeholder="Header - click to edit" spellcheck="false"></div>
+                <!-- page one's header / footer; docs.js clones a copy into
+                     every other sheet's margin band (layoutHeaderFooters) -->
+                <div id="docHeader" class="doc-hf doc-hf-header" contenteditable="true"
+                     data-hf="header" data-placeholder="Header - click to edit"
+                     spellcheck="false"></div>
                 <div id="editor" contenteditable="true" spellcheck="true"></div>
-                <div id="docFooter" class="doc-hf" contenteditable="true"
-                     data-placeholder="Footer - click to edit" spellcheck="false"></div>
+                <div id="docFooter" class="doc-hf doc-hf-footer" contenteditable="true"
+                     data-hf="footer" data-placeholder="Footer - click to edit"
+                     spellcheck="false"></div>
             </div>
         </div>
     </div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است