diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md
index 0b78268e..96e23432 100644
--- a/FEATURE_PARITY.md
+++ b/FEATURE_PARITY.md
@@ -178,7 +178,7 @@ spaces consistently for wrapping, run positioning, alignment, and run shading.
| Fonts, fills, borders, alignment | I | P | P | M | M | B |
| Row heights and column widths | I | I | P | P | M | B |
| Merged cells | I | I | I | M | M | B |
-| Hidden rows, columns, and sheets | I | P | P | M | M | B |
+| Hidden rows, columns, and sheets | I | P | P | P | M | B |
| Print area and print titles | I | P | P | M | M | B |
| Paper size, margins, orientation | I | P | P | P | M | B |
| Fit-to-page and print scale | I | P | P | M | M | B |
diff --git a/minipdf-go/office_test.go b/minipdf-go/office_test.go
index 8390a4a9..d96458e4 100644
--- a/minipdf-go/office_test.go
+++ b/minipdf-go/office_test.go
@@ -235,6 +235,148 @@ func TestConvertXLSXSkipsEmptyWorksheets(t *testing.T) {
}
}
+func TestConvertXLSXSkipsHiddenWorksheets(t *testing.T) {
+ sheet := func(text string) string {
+ return `` +
+ `` + text + `
`
+ }
+ input := officePackageBytes(t, map[string]string{
+ "xl/workbook.xml": `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``,
+ "xl/_rels/workbook.xml.rels": `` +
+ `` +
+ `` +
+ `` +
+ `` +
+ ``,
+ "xl/worksheets/sheet1.xml": sheet("Visible report"),
+ "xl/worksheets/data.xml": sheet("Hidden lookup"),
+ "xl/worksheets/sheet3.xml": sheet("Very hidden internal"),
+ "xl/worksheets/sheet2.xml": sheet("Visible summary"),
+ })
+
+ pdf, err := ConvertBytesToPDF(input)
+ assertPDFContains(t, pdf, err, "Visible report", "Visible summary")
+ for _, text := range []string{"Hidden lookup", "Very hidden internal"} {
+ if bytes.Contains(pdf, []byte(text)) {
+ t.Fatalf("PDF contains hidden worksheet text %q", text)
+ }
+ }
+ if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 2 {
+ t.Fatalf("page count = %d, want 2", pages)
+ }
+}
+
+func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) {
+ const (
+ visibleA = ``
+ hiddenA = ``
+ hiddenB = ``
+ visibleB = ``
+ first = ``
+ second = ``
+ )
+ tests := []struct {
+ name string
+ sheets string
+ relationships []string
+ parts [2]string
+ present []string
+ absent []string
+ pages int
+ }{
+ {
+ name: "missing relationships map sheets to non-contiguous parts by position",
+ sheets: hiddenA + visibleB,
+ parts: [2]string{"sheet2.xml", "sheet3.xml"},
+ present: []string{"Second part"}, absent: []string{"First part"}, pages: 1,
+ },
+ {
+ name: "missing relationships map sheets to custom part names by position",
+ sheets: hiddenA + visibleB,
+ parts: [2]string{"data.xml", "summary.xml"},
+ present: []string{"Second part"}, absent: []string{"First part"}, pages: 1,
+ },
+ {
+ name: "visible sheet whose target is not a worksheet part is kept",
+ sheets: visibleA + hiddenB,
+ relationships: []string{``, second},
+ present: []string{"First part"}, absent: []string{"Second part"}, pages: 1,
+ },
+ {
+ name: "missing relationships map sheets to parts by position",
+ sheets: visibleA + hiddenB,
+ present: []string{"First part"}, absent: []string{"Second part"}, pages: 1,
+ },
+ {
+ name: "hidden sheet with a dangling relationship id is not rendered",
+ sheets: visibleA + ``,
+ relationships: []string{``},
+ present: []string{"Second part"}, absent: []string{"First part"}, pages: 1,
+ },
+ {
+ name: "visible sheet with a dangling relationship id is kept",
+ sheets: `` + hiddenB,
+ relationships: []string{second},
+ present: []string{"First part"}, absent: []string{"Second part"}, pages: 1,
+ },
+ {
+ name: "visible sheet whose target matches no part is kept",
+ sheets: visibleA + hiddenB,
+ relationships: []string{``, second},
+ present: []string{"First part"}, absent: []string{"Second part"}, pages: 1,
+ },
+ {
+ name: "relationship targets match part names case-insensitively",
+ sheets: visibleA + hiddenB,
+ relationships: []string{first, ``},
+ present: []string{"First part"}, absent: []string{"Second part"}, pages: 1,
+ },
+ {
+ name: "all sheets hidden produce one blank page",
+ sheets: hiddenA + hiddenB,
+ relationships: []string{first, second},
+ absent: []string{"First part", "Second part"}, pages: 1,
+ },
+ {
+ name: "all sheets hidden without relationships produce one blank page",
+ sheets: hiddenA + hiddenB,
+ absent: []string{"First part", "Second part"}, pages: 1,
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ parts := test.parts
+ if parts == [2]string{} {
+ parts = [2]string{"sheet1.xml", "sheet2.xml"}
+ }
+ entries := map[string]string{
+ "xl/workbook.xml": `` +
+ test.sheets + ``,
+ "xl/worksheets/" + parts[0]: `First part
`,
+ "xl/worksheets/" + parts[1]: `Second part
`,
+ }
+ if test.relationships != nil {
+ entries["xl/_rels/workbook.xml.rels"] = `` + strings.Join(test.relationships, "") + ``
+ }
+ pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries))
+ assertPDFContains(t, pdf, err, test.present...)
+ for _, text := range test.absent {
+ if bytes.Contains(pdf, []byte(text)) {
+ t.Fatalf("PDF contains hidden worksheet text %q", text)
+ }
+ }
+ if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != test.pages {
+ t.Fatalf("page count = %d, want %d", pages, test.pages)
+ }
+ })
+ }
+}
+
func TestSplitWorksheetColumnGroups(t *testing.T) {
lines := []string{
"A\tB\tC\tD\tE\tF\tG\tH\tI\tJ",
diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go
index 7800a98d..53d7ebf8 100644
--- a/minipdf-go/xlsx.go
+++ b/minipdf-go/xlsx.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/xml"
"fmt"
+ "io"
"strconv"
"strings"
)
@@ -21,6 +22,10 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) {
if len(worksheets) == 0 {
return nil, errorsNewMissingWorksheets()
}
+ worksheets, err = visibleXLSXWorksheets(files, worksheets)
+ if err != nil {
+ return nil, err
+ }
styles, err := readXLSXStyles(files)
if err != nil {
return nil, err
@@ -86,6 +91,96 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) {
return renderTextPages(pages, options), nil
}
+// visibleXLSXWorksheets returns the worksheet parts that Excel and LibreOffice
+// would print: those that belong to a sheet xl/workbook.xml does not mark
+// hidden or veryHidden.
+func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, error) {
+ const workbookName = "xl/workbook.xml"
+ if _, ok := files[workbookName]; !ok {
+ return worksheets, nil
+ }
+ data, err := files.read(workbookName)
+ if err != nil {
+ return nil, err
+ }
+ // Part names are case-insensitive, so targets are compared in lower case.
+ targets := make(map[string]string)
+ relationshipsName := xlsxRelationshipPartName(workbookName)
+ _, hasRelationships := files[relationshipsName]
+ if hasRelationships {
+ relationshipsData, readErr := files.read(relationshipsName)
+ if readErr != nil {
+ return nil, readErr
+ }
+ var relationships xlsxRelationshipsDocument
+ if err := xml.Unmarshal(relationshipsData, &relationships); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", relationshipsName, err)
+ }
+ for _, relationship := range relationships.Items {
+ targets[relationship.ID] = strings.ToLower(xlsxResolveRelationshipTarget(workbookName, relationship.Target))
+ }
+ }
+ worksheetParts := make(map[string]bool, len(worksheets))
+ for _, name := range worksheets {
+ worksheetParts[strings.ToLower(name)] = true
+ }
+ visibleParts := make(map[string]bool)
+ hiddenParts := make(map[string]bool)
+ sheetCount := 0
+ unresolvedVisibleSheet := false
+ decoder := xml.NewDecoder(bytes.NewReader(data))
+ for {
+ token, tokenErr := decoder.Token()
+ if tokenErr == io.EOF {
+ break
+ }
+ if tokenErr != nil {
+ return nil, fmt.Errorf("parse %s: %w", workbookName, tokenErr)
+ }
+ start, ok := token.(xml.StartElement)
+ if !ok || start.Name.Local != "sheet" {
+ continue
+ }
+ sheetCount++
+ state := attrValue(start, "state")
+ isHidden := state == "hidden" || state == "veryHidden"
+ var part string
+ if hasRelationships {
+ part = targets[attrValue(start, "id")]
+ } else if sheetCount <= len(worksheets) {
+ // Without workbook relationships, sheets map to the ordered
+ // worksheet parts by position.
+ part = strings.ToLower(worksheets[sheetCount-1])
+ }
+ switch {
+ case isHidden:
+ hiddenParts[part] = true
+ case !worksheetParts[part]:
+ unresolvedVisibleSheet = true
+ default:
+ visibleParts[part] = true
+ }
+ }
+ // When every visible sheet resolves to a worksheet part, render exactly those
+ // parts so that a hidden sheet with a broken relationship is never printed.
+ // Otherwise only drop the parts known to be hidden, so no visible sheet is
+ // lost. A visible chartsheet resolves to no worksheet part and therefore
+ // selects the second mode.
+ keepVisibleOnly := hasRelationships && sheetCount > 0 && !unresolvedVisibleSheet
+ visible := make([]string, 0, len(worksheets))
+ for _, name := range worksheets {
+ key := strings.ToLower(name)
+ keep := !hiddenParts[key]
+ if keepVisibleOnly {
+ keep = visibleParts[key]
+ }
+ if keep {
+ visible = append(visible, name)
+ }
+ }
+ return visible, nil
+}
+
func limitWorksheet(lines []string, maxRows, maxColumns int) []string {
if maxRows > 0 && len(lines) > maxRows {
lines = lines[:maxRows]