diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index d722b90f..b9462b29 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -293,6 +293,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input return errors.New("LibreOffice not started, cannot handle PDF conversion") } + // SinglePageSheets starts each sheet's single page at the workbook's saved + // scroll position, truncating everything above and to the left of it. + // Render a copy with that position reset to the top-left cell instead. + // See https://github.com/gotenberg/gotenberg/issues/1222. + if options.SinglePageSheets { + inputPath = resetCalcScrollPosition(ctx, logger, inputPath) + } + args := []string{ "--no-launch", "--format", diff --git a/pkg/modules/libreoffice/api/singlepagesheets.go b/pkg/modules/libreoffice/api/singlepagesheets.go new file mode 100644 index 00000000..ce2f5549 --- /dev/null +++ b/pkg/modules/libreoffice/api/singlepagesheets.go @@ -0,0 +1,230 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" +) + +// topLeftCellAttr matches the topLeftCell attribute that an OOXML worksheet +// uses (on and, for frozen panes, ) to store the cell that +// was at the top-left of the window when the workbook was saved. +var topLeftCellAttr = regexp.MustCompile(` topLeftCell="[^"]*"`) + +// maxDecompressedWorksheet bounds how much a single worksheet may decompress to +// while rewriting it. It guards against a decompression bomb and keeps memory +// predictable. A worksheet larger than this is left untouched, so a pathological +// workbook falls back to the original file rather than being rewritten. +const maxDecompressedWorksheet = 128 << 20 // 128 MiB + +// resetCalcScrollPosition returns a path to a copy of inputPath whose worksheet +// scroll positions have been reset to the top-left cell, or inputPath unchanged +// when the reset does not apply or cannot be performed safely. +// +// LibreOffice's SinglePageSheets export starts each single page at the sheet's +// saved topLeftCell, dropping every row and column above and to the left of it. +// A workbook saved scrolled away from A1 therefore renders truncated. Removing +// the attribute before the conversion makes the whole used range render. +// See https://github.com/gotenberg/gotenberg/issues/1222. +// +// The function never fails the conversion. On a non-xlsx input, a workbook that +// carries no scroll position, or any read, rewrite or validation error, it +// returns the original path so a malformed rewrite can never reach LibreOffice. +func resetCalcScrollPosition(ctx context.Context, logger *slog.Logger, inputPath string) string { + // Resolve the extension to a literal so the sanitized filename is never + // derived from the (user-controlled) upload name. + var ext string + switch strings.ToLower(filepath.Ext(inputPath)) { + case ".xlsx": + ext = ".xlsx" + case ".xlsm": + ext = ".xlsm" + default: + return inputPath + } + + src, err := os.ReadFile(inputPath) + if err != nil { + logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: read input: %s; using the original file", err)) + return inputPath + } + + out, changed, err := stripWorksheetScrollPosition(src) + if err != nil { + logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err)) + return inputPath + } + if !changed { + // The common case: nothing was saved scrolled, so nothing to do. + return inputPath + } + + // A rewrite that dropped, renamed or corrupted an entry must never reach + // LibreOffice; fall back to the original workbook if it does not round-trip. + if err = validateWorkbook(src, out); err != nil { + logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: %s; using the original file", err)) + return inputPath + } + + // Write the sanitized copy alongside the input, inside the request working + // directory that LibreOffice already reads from. The pattern is constant, + // so the resulting name carries no user-controlled path component. + dst, err := os.CreateTemp(filepath.Dir(inputPath), "singlepagesheets-*"+ext) + if err != nil { + logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: create sanitized file: %s; using the original file", err)) + return inputPath + } + defer dst.Close() + + _, err = dst.Write(out) + if err != nil { + _ = os.Remove(dst.Name()) + logger.WarnContext(ctx, fmt.Sprintf("reset calc scroll position: write sanitized file: %s; using the original file", err)) + return inputPath + } + + logger.DebugContext(ctx, "reset calc scroll position: cleared worksheet topLeftCell for SinglePageSheets export") + return dst.Name() +} + +// stripWorksheetScrollPosition rewrites the worksheet XML entries of an xlsx +// workbook, removing the topLeftCell attribute, and reports whether anything +// changed. Every non-worksheet entry, and every worksheet that does not carry +// the attribute, is copied byte-for-byte without recompression. +func stripWorksheetScrollPosition(src []byte) ([]byte, bool, error) { + reader, err := zip.NewReader(bytes.NewReader(src), int64(len(src))) + if err != nil { + return nil, false, fmt.Errorf("open workbook: %w", err) + } + + var buf bytes.Buffer + writer := zip.NewWriter(&buf) + changed := false + + for _, file := range reader.File { + rewritten, ok, err := rewriteWorksheet(file) + if err != nil { + return nil, false, err + } + + if ok { + // Recompress only the worksheets that actually changed. + header := file.FileHeader + header.Method = zip.Deflate + w, err := writer.CreateHeader(&header) + if err != nil { + return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err) + } + _, err = w.Write(rewritten) + if err != nil { + return nil, false, fmt.Errorf("write worksheet %q: %w", file.Name, err) + } + changed = true + continue + } + + err = copyZipEntry(writer, file) + if err != nil { + return nil, false, err + } + } + + err = writer.Close() + if err != nil { + return nil, false, fmt.Errorf("finalize workbook: %w", err) + } + if !changed { + return nil, false, nil + } + return buf.Bytes(), true, nil +} + +// rewriteWorksheet returns file's contents with topLeftCell removed, and +// whether file is a worksheet that carried the attribute. A worksheet without +// the attribute, or any other entry, returns ok false so the caller copies it +// verbatim. +func rewriteWorksheet(file *zip.File) ([]byte, bool, error) { + if !strings.HasPrefix(file.Name, "xl/worksheets/") || !strings.HasSuffix(strings.ToLower(file.Name), ".xml") { + return nil, false, nil + } + + rc, err := file.Open() + if err != nil { + return nil, false, fmt.Errorf("open worksheet %q: %w", file.Name, err) + } + defer rc.Close() + + // Read at most maxDecompressedWorksheet+1 bytes so a decompression bomb + // cannot exhaust memory; a genuine overflow aborts the rewrite. + data, err := io.ReadAll(io.LimitReader(rc, maxDecompressedWorksheet+1)) + if err != nil { + return nil, false, fmt.Errorf("read worksheet %q: %w", file.Name, err) + } + if len(data) > maxDecompressedWorksheet { + return nil, false, fmt.Errorf("worksheet %q exceeds %d bytes", file.Name, maxDecompressedWorksheet) + } + + if !bytes.Contains(data, []byte("topLeftCell")) { + return nil, false, nil + } + return topLeftCellAttr.ReplaceAll(data, nil), true, nil +} + +// copyZipEntry writes file into writer without decompressing and recompressing +// it, preserving its exact bytes. +func copyZipEntry(writer *zip.Writer, file *zip.File) error { + w, err := writer.CreateRaw(&file.FileHeader) + if err != nil { + return fmt.Errorf("copy entry %q: %w", file.Name, err) + } + rc, err := file.OpenRaw() + if err != nil { + return fmt.Errorf("open entry %q: %w", file.Name, err) + } + _, err = io.Copy(w, rc) + if err != nil { + return fmt.Errorf("copy entry %q: %w", file.Name, err) + } + return nil +} + +// validateWorkbook checks that out reopens as a zip holding exactly the same +// entry names as src, rejecting a rewrite that lost, renamed or added an entry +// or produced a broken central directory. The entry payloads themselves are +// not re-read: unchanged entries are copied byte-for-byte from a workbook that +// already parsed, and rewritten worksheets are produced by the standard library +// writer, so re-decompressing everything would only add a decompression-bomb +// surface without catching a failure this transform can introduce. +func validateWorkbook(src, out []byte) error { + original, err := zip.NewReader(bytes.NewReader(src), int64(len(src))) + if err != nil { + return fmt.Errorf("reopen original workbook: %w", err) + } + rewritten, err := zip.NewReader(bytes.NewReader(out), int64(len(out))) + if err != nil { + return fmt.Errorf("reopen rewritten workbook: %w", err) + } + + if len(rewritten.File) != len(original.File) { + return fmt.Errorf("entry count changed from %d to %d", len(original.File), len(rewritten.File)) + } + + names := make(map[string]struct{}, len(original.File)) + for _, file := range original.File { + names[file.Name] = struct{}{} + } + for _, file := range rewritten.File { + _, ok := names[file.Name] + if !ok { + return fmt.Errorf("unexpected entry %q", file.Name) + } + } + return nil +} diff --git a/pkg/modules/libreoffice/api/singlepagesheets_test.go b/pkg/modules/libreoffice/api/singlepagesheets_test.go new file mode 100644 index 00000000..0b8fd217 --- /dev/null +++ b/pkg/modules/libreoffice/api/singlepagesheets_test.go @@ -0,0 +1,216 @@ +package api + +import ( + "archive/zip" + "bytes" + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +// buildWorkbook packs entries into an in-memory xlsx-like zip. +func buildWorkbook(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for name, content := range entries { + f, err := w.Create(name) + if err != nil { + t.Fatalf("create entry %q: %v", name, err) + } + _, err = f.Write([]byte(content)) + if err != nil { + t.Fatalf("write entry %q: %v", name, err) + } + } + err := w.Close() + if err != nil { + t.Fatalf("close workbook: %v", err) + } + return buf.Bytes() +} + +func readEntry(t *testing.T, workbook []byte, name string) string { + t.Helper() + r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook))) + if err != nil { + t.Fatalf("open workbook: %v", err) + } + for _, f := range r.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatalf("open entry %q: %v", name, err) + } + defer rc.Close() + data, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("read entry %q: %v", name, err) + } + return string(data) + } + t.Fatalf("entry %q not found", name) + return "" +} + +const scrolledSheet = `` + + `` + + `` + + `1` + +const topSheet = `` + + `` + + `1` + +func TestStripWorksheetScrollPosition(t *testing.T) { + for _, tc := range []struct { + scenario string + workbook []byte + expectErr bool + expectChange bool + }{ + { + scenario: "removes topLeftCell from sheetView and pane", + workbook: buildWorkbook(t, map[string]string{ + "[Content_Types].xml": "", + "xl/worksheets/sheet1.xml": scrolledSheet, + "xl/sharedStrings.xml": "", + }), + expectChange: true, + }, + { + scenario: "leaves a workbook without a saved scroll position untouched", + workbook: buildWorkbook(t, map[string]string{ + "[Content_Types].xml": "", + "xl/worksheets/sheet1.xml": topSheet, + }), + expectChange: false, + }, + { + scenario: "only rewrites worksheet entries", + workbook: buildWorkbook(t, map[string]string{ + "xl/worksheets/sheet1.xml": scrolledSheet, + // A stray topLeftCell elsewhere must not be touched. + "xl/workbook.xml": ``, + }), + expectChange: true, + }, + { + scenario: "rejects a non-zip input", + workbook: []byte("not a zip file"), + expectErr: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + out, changed, err := stripWorksheetScrollPosition(tc.workbook) + + if tc.expectErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed != tc.expectChange { + t.Fatalf("expected changed=%v, got %v", tc.expectChange, changed) + } + if !changed { + return + } + + // The rewrite must round-trip and hold the same entries. + if err = validateWorkbook(tc.workbook, out); err != nil { + t.Fatalf("rewritten workbook did not validate: %v", err) + } + if strings.Contains(readEntry(t, out, "xl/worksheets/sheet1.xml"), "topLeftCell") { + t.Fatalf("worksheet still contains topLeftCell") + } + // Non-worksheet entries are copied verbatim. + if _, ok := entryNames(t, out)["xl/workbook.xml"]; ok { + if got := readEntry(t, out, "xl/workbook.xml"); got != `` { + t.Fatalf("non-worksheet entry was modified: %q", got) + } + } + }) + } +} + +func entryNames(t *testing.T, workbook []byte) map[string]struct{} { + t.Helper() + r, err := zip.NewReader(bytes.NewReader(workbook), int64(len(workbook))) + if err != nil { + t.Fatalf("open workbook: %v", err) + } + names := make(map[string]struct{}, len(r.File)) + for _, f := range r.File { + names[f.Name] = struct{}{} + } + return names +} + +func TestResetCalcScrollPosition(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx := context.Background() + + writeFile := func(t *testing.T, name string, content []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + err := os.WriteFile(path, content, 0o600) + if err != nil { + t.Fatalf("write %q: %v", name, err) + } + return path + } + + t.Run("non-xlsx input is returned unchanged", func(t *testing.T) { + path := writeFile(t, "input.docx", []byte("whatever")) + if got := resetCalcScrollPosition(ctx, logger, path); got != path { + t.Fatalf("expected %q, got %q", path, got) + } + }) + + t.Run("workbook without a scroll position is returned unchanged", func(t *testing.T) { + path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{ + "xl/worksheets/sheet1.xml": topSheet, + })) + if got := resetCalcScrollPosition(ctx, logger, path); got != path { + t.Fatalf("expected original path %q, got %q", path, got) + } + }) + + t.Run("corrupt xlsx falls back to the original path", func(t *testing.T) { + path := writeFile(t, "input.xlsx", []byte("PK\x03\x04 not really a zip")) + if got := resetCalcScrollPosition(ctx, logger, path); got != path { + t.Fatalf("expected fallback to %q, got %q", path, got) + } + }) + + t.Run("scrolled workbook yields a sanitized copy", func(t *testing.T) { + path := writeFile(t, "input.xlsx", buildWorkbook(t, map[string]string{ + "[Content_Types].xml": "", + "xl/worksheets/sheet1.xml": scrolledSheet, + })) + got := resetCalcScrollPosition(ctx, logger, path) + if got == path { + t.Fatalf("expected a sanitized copy, got the original path") + } + if filepath.Dir(got) != filepath.Dir(path) { + t.Fatalf("sanitized copy escaped the working directory: %q", got) + } + sanitized, err := os.ReadFile(got) + if err != nil { + t.Fatalf("read sanitized file: %v", err) + } + if strings.Contains(readEntry(t, sanitized, "xl/worksheets/sheet1.xml"), "topLeftCell") { + t.Fatalf("sanitized worksheet still contains topLeftCell") + } + }) +} diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 4edce46b..7ae189d7 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -964,3 +964,17 @@ Feature: /forms/libreoffice/convert Then the response status code should be 200 Then there should be 1 PDF(s) in the response Then the "foo.pdf" PDF should have 0 image(s) + + # The workbook was saved scrolled down to row 37. Without the topLeftCell + # reset, SinglePageSheets would start the page there and drop the header rows, + # so the "Meteor" column header only appears when the whole sheet is rendered. + # See https://github.com/gotenberg/gotenberg/issues/1222. + Scenario: POST /forms/libreoffice/convert (SinglePageSheets renders a scrolled workbook in full) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): + | files | testdata/singlepagesheets-scrolled.xlsx | file | + | singlePageSheets | true | field | + | Gotenberg-Output-Filename | foo | header | + Then the response status code should be 200 + Then there should be 1 PDF(s) in the response + Then the "foo.pdf" PDF should have content matching "Meteor" at page 1 diff --git a/test/integration/testdata/singlepagesheets-scrolled.xlsx b/test/integration/testdata/singlepagesheets-scrolled.xlsx new file mode 100644 index 00000000..c16eb3cc Binary files /dev/null and b/test/integration/testdata/singlepagesheets-scrolled.xlsx differ