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]