mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-18 13:12:17 +01:00
fix(libreoffice): render scrolled workbooks in full for SinglePageSheets
This commit is contained in:
@@ -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")
|
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{
|
args := []string{
|
||||||
"--no-launch",
|
"--no-launch",
|
||||||
"--format",
|
"--format",
|
||||||
|
|||||||
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
230
pkg/modules/libreoffice/api/singlepagesheets.go
Normal file
@@ -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 <sheetView> and, for frozen panes, <pane>) 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
|
||||||
|
}
|
||||||
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
216
pkg/modules/libreoffice/api/singlepagesheets_test.go
Normal file
@@ -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 = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" topLeftCell="A37" workbookViewId="0">` +
|
||||||
|
`<pane topLeftCell="A37"/><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
const topSheet = `<?xml version="1.0"?><worksheet><dimension ref="A1:B83"/>` +
|
||||||
|
`<sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>` +
|
||||||
|
`<sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>`
|
||||||
|
|
||||||
|
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": "<Types/>",
|
||||||
|
"xl/worksheets/sheet1.xml": scrolledSheet,
|
||||||
|
"xl/sharedStrings.xml": "<sst/>",
|
||||||
|
}),
|
||||||
|
expectChange: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scenario: "leaves a workbook without a saved scroll position untouched",
|
||||||
|
workbook: buildWorkbook(t, map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"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": `<workbook topLeftCell="A9"/>`,
|
||||||
|
}),
|
||||||
|
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 != `<workbook topLeftCell="A9"/>` {
|
||||||
|
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": "<Types/>",
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -964,3 +964,17 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response status code should be 200
|
Then the response status code should be 200
|
||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
Then the "foo.pdf" PDF should have 0 image(s)
|
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
|
||||||
|
|||||||
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
BIN
test/integration/testdata/singlepagesheets-scrolled.xlsx
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user