瀏覽代碼

Clip overflowing sheet cell text in PDFs

Add `pdfFitText` to truncate overlong cell content to column width before drawing, appending an ellipsis when possible and preserving rune boundaries for multibyte text. Wire it into sheet PDF rendering so `CellFormat` no longer paints text into neighboring columns. Expand tests to cover fit/overflow behavior, width guarantees, multibyte safety, and a regression case proving long Message cells no longer overlap adjacent columns.
Toby Chui 3 天之前
父節點
當前提交
468db3daa7
共有 4 個文件被更改,包括 158 次插入1 次删除
  1. 45 0
      src/mod/office/pdf.go
  2. 4 1
      src/mod/office/pdf_sheet.go
  3. 102 0
      src/mod/office/pdf_test.go
  4. 7 0
      src/web/Office/README.md

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

@@ -120,3 +120,48 @@ func pdfTr(pdf *fpdf.Fpdf) func(string) string {
 		return tr(pdfNbsp.Replace(s))
 	}
 }
+
+// pdfFitText trims s to what fits in maxW at the CURRENT font, ending it with
+// an ellipsis when anything was cut, and returns it translated ready to draw.
+//
+// fpdf's CellFormat does not clip: a string wider than its cell is drawn
+// straight across the neighbouring ones. A spreadsheet column is exactly as
+// wide as the sheet says it is, so overlong cells have to be shortened here
+// the way the on-screen grid hides them with overflow:hidden.
+//
+// Widths are measured on the translated text (that is what actually gets
+// drawn) while the cut is made on runes of the original, so a multi-byte
+// character is never split in half.
+func pdfFitText(pdf *fpdf.Fpdf, tr func(string) string, s string, maxW float64) string {
+	if s == "" || maxW <= 0 {
+		return ""
+	}
+	full := tr(s)
+	if pdf.GetStringWidth(full) <= maxW {
+		return full
+	}
+	ell := tr("…")
+	if pdf.GetStringWidth(ell) <= 0 {
+		ell = tr("...") // cp1252 has an ellipsis, but never depend on it
+	}
+	ellW := pdf.GetStringWidth(ell)
+	if ellW > maxW {
+		return "" // column too narrow even for the marker: draw nothing
+	}
+	// longest prefix that still leaves room for the ellipsis; prefix width
+	// grows with length, so a binary search finds it directly
+	runes := []rune(s)
+	lo, hi := 0, len(runes)
+	for lo < hi {
+		mid := (lo + hi + 1) / 2
+		if pdf.GetStringWidth(tr(string(runes[:mid])))+ellW <= maxW {
+			lo = mid
+		} else {
+			hi = mid - 1
+		}
+	}
+	if lo == 0 {
+		return ell // not even one character fits alongside it
+	}
+	return tr(string(runes[:lo])) + ell
+}

+ 4 - 1
src/mod/office/pdf_sheet.go

@@ -143,8 +143,11 @@ func BuildSheetPdf(m *SheetPrintModel) ([]byte, error) {
 					if align == "" {
 						align = "L"
 					}
+					// the cell is only as wide as its column: trim rather
+					// than let fpdf draw over the next column (pdfFitText)
+					innerW := widths[c] - 1.6
 					pdf.SetXY(x+0.8, y)
-					pdf.CellFormat(widths[c]-1.6, rowH, tr(cell.T), "", 0, align, false, 0, "")
+					pdf.CellFormat(innerW, rowH, pdfFitText(pdf, tr, cell.T, innerW), "", 0, align, false, 0, "")
 				}
 				x += widths[c]
 			}

+ 102 - 0
src/mod/office/pdf_test.go

@@ -518,6 +518,108 @@ func TestSheetPdf(t *testing.T) {
 	}
 }
 
+func TestPdfFitText(t *testing.T) {
+	pdf := fpdf.New("L", "mm", "A4", "")
+	pdf.AddPage()
+	pdf.SetFont("Arial", "", 9)
+	tr := pdfTr(pdf)
+
+	wide := pdf.GetStringWidth(tr("2026-08-24 21:01:24")) + 1
+
+	tests := []struct {
+		name string
+		in   string
+		maxW float64
+		want string // "" means: expect exactly the translated input back
+	}{
+		{"fits untouched", "2026-08-24 21:01:24", wide, ""},
+		{"empty stays empty", "", wide, ""},
+		{"zero width yields nothing", "anything", 0, ""},
+		{"negative width yields nothing", "anything", -5, ""},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := pdfFitText(pdf, tr, tc.in, tc.maxW)
+			want := tc.want
+			if want == "" && tc.maxW > 0 {
+				want = tr(tc.in)
+			}
+			if got != want {
+				t.Errorf("pdfFitText(%q, %v) = %q, want %q", tc.in, tc.maxW, got, want)
+			}
+		})
+	}
+
+	// the real job: a string too wide for its cell comes back shortened,
+	// never wider than the cell it has to sit in
+	t.Run("overlong text is trimmed to fit", func(t *testing.T) {
+		long := "need help with my Deployment configuration"
+		narrow := pdf.GetStringWidth(tr(long)) / 3
+		got := pdfFitText(pdf, tr, long, narrow)
+		if w := pdf.GetStringWidth(got); w > narrow {
+			t.Errorf("fitted text is %v wide, cell is only %v", w, narrow)
+		}
+		if got == tr(long) {
+			t.Error("overlong text was returned unchanged")
+		}
+		if !strings.HasPrefix(got, tr("need")) {
+			t.Errorf("trimmed text lost its start: %q", got)
+		}
+	})
+
+	// a cut must not land in the middle of a multi-byte character
+	t.Run("multi-byte text is cut on rune boundaries", func(t *testing.T) {
+		s := "café crème brûlée gâteau"
+		got := pdfFitText(pdf, tr, s, pdf.GetStringWidth(tr(s))/2)
+		if w := pdf.GetStringWidth(got); w > pdf.GetStringWidth(tr(s))/2 {
+			t.Errorf("fitted text %q overflows", got)
+		}
+		if got == "" {
+			t.Error("multi-byte text was dropped entirely")
+		}
+	})
+
+	// whatever comes back must fit, however little room there is - including
+	// a column too narrow for even the ellipsis
+	t.Run("never wider than the cell", func(t *testing.T) {
+		for _, maxW := range []float64{0.5, 1, 2, 3, 5, 10, 25} {
+			got := pdfFitText(pdf, tr, "abcdefghij klmnop", maxW)
+			if w := pdf.GetStringWidth(got); w > maxW {
+				t.Errorf("maxW=%v: got %q which is %v wide", maxW, got, w)
+			}
+		}
+	})
+}
+
+// the bug this pins down: fpdf's CellFormat does not clip, so a cell wider
+// than its column used to be painted straight over the next column
+func TestSheetPdfClipsOverlongCells(t *testing.T) {
+	overflow := "need help with my Deployment configuration"
+	// the Name column is wide enough for its value; the Message column is not
+	m := &SheetPrintModel{Sheets: []*SheetPrintSheet{
+		{Name: "contact-form", ColW: []float64{140, 200, 120, 90},
+			Rows: [][]*SheetPrintCell{
+				{{T: "Submitted at", B: true}, {T: "Name", B: true}, {T: "Email", B: true}, {T: "Message", B: true}},
+				{{T: "2026-08-24 21:01:24"}, {T: "Yami Odymel"}, {T: "yami@foobar.com"}, {T: overflow}},
+			}},
+	}}
+	data, err := BuildSheetPdf(m)
+	if err != nil {
+		t.Fatalf("BuildSheetPdf: %v", err)
+	}
+	text := pdfStreamsText(t, data)
+	if strings.Contains(text, overflow) {
+		t.Error("a cell too wide for its column was drawn in full and overlaps its neighbour")
+	}
+	// short cells must still be written out untouched
+	if !strings.Contains(text, "Yami Odymel") {
+		t.Error("a cell that fits its column was trimmed anyway")
+	}
+	if !strings.Contains(text, "need") {
+		t.Error("the trimmed cell lost its leading text entirely")
+	}
+}
+
 func TestParseSheetPrintJSON(t *testing.T) {
 	if _, err := ParseSheetPrintJSON("{"); err == nil {
 		t.Error("invalid JSON accepted")

+ 7 - 0
src/web/Office/README.md

@@ -199,6 +199,13 @@ the path that honours every mode exactly.
   [`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` / `pdf_doc.go`:
+  - **`CellFormat` does not clip.** A string wider than its cell is drawn
+    straight across the neighbouring columns, which in Sheets exports read
+    as overlapping garbage (`2026-08-24 21:01:2Yami Odymel`). Every cell
+    draw must go through `pdfFitText()` (`pdf.go`), which trims to the
+    column width and marks the cut with an ellipsis — the print equivalent
+    of the grid's `overflow: hidden`. It measures the *translated* text but
+    cuts on runes of the original, so multi-byte characters never split.
   - 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