From a5b421504565b4c3aed0800f0f316b0138032465 Mon Sep 17 00:00:00 2001 From: henry3260 Date: Thu, 12 Nov 2026 22:05:28 +0800 Subject: [PATCH 1/2] fix(go): skip hidden worksheets in XLSX conversion The Go converter rendered every worksheet part in the package, including sheets that xl/workbook.xml marks as hidden or veryHidden. Excel and LibreOffice never print those sheets, and the .NET and Rust implementations already skip them. Resolve each sheet through the workbook relationships. When every visible sheet resolves to a part in the package, render exactly those parts, so a hidden sheet with a broken relationship or an orphaned worksheet part is never printed. If a visible sheet cannot be resolved, only drop the parts known to be hidden so that no visible sheet is lost. Without a relationships part, sheets map to parts by position. Relationship targets match part names case-insensitively. Mark Go as partial for hidden sheets in FEATURE_PARITY.md. --- FEATURE_PARITY.md | 2 +- minipdf-go/office_test.go | 118 ++++++++++++++++++++++++++++++++++++++ minipdf-go/xlsx.go | 92 +++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) 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..f713f5c3 100644 --- a/minipdf-go/office_test.go +++ b/minipdf-go/office_test.go @@ -235,6 +235,124 @@ 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 = `` + first = `` + second = `` + ) + tests := []struct { + name string + sheets string + relationships []string + present []string + absent []string + pages int + }{ + { + 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) { + entries := map[string]string{ + "xl/workbook.xml": `` + + test.sheets + ``, + "xl/worksheets/sheet1.xml": `First part`, + "xl/worksheets/sheet2.xml": `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..95e1399f 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,93 @@ 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)) + } + } + packageParts := make(map[string]bool, len(files)) + for name := range files { + packageParts[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 { + // Without workbook relationships, sheets map to parts by position. + part = fmt.Sprintf("xl/worksheets/sheet%d.xml", sheetCount) + } + switch { + case isHidden: + hiddenParts[part] = true + case !packageParts[part]: + unresolvedVisibleSheet = true + default: + visibleParts[part] = true + } + } + // When every visible sheet resolves to a package 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. + 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] From af064b176d02964cee540ff22dad453eb5c400e1 Mon Sep 17 00:00:00 2001 From: henry3260 Date: Thu, 12 Nov 2026 22:32:34 +0800 Subject: [PATCH 2/2] fix(go): resolve hidden sheets against discovered worksheet parts Address review comments on the hidden worksheet change. Without workbook relationships, map each sheet to the ordered worksheet part at its position instead of a synthesized sheetN.xml name, so non-contiguous and custom part names hide the right sheet. Count a visible sheet as resolved only when its target is a discovered worksheet part. A target that names another package part, such as xl/styles.xml, now falls back to dropping known-hidden parts instead of dropping the visible sheet. --- minipdf-go/office_test.go | 28 ++++++++++++++++++++++++++-- minipdf-go/xlsx.go | 23 +++++++++++++---------- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/minipdf-go/office_test.go b/minipdf-go/office_test.go index f713f5c3..d96458e4 100644 --- a/minipdf-go/office_test.go +++ b/minipdf-go/office_test.go @@ -276,6 +276,7 @@ func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { visibleA = `` hiddenA = `` hiddenB = `` + visibleB = `` first = `` second = `` ) @@ -283,10 +284,29 @@ func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { 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, @@ -330,11 +350,15 @@ func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { } 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/sheet1.xml": `First part`, - "xl/worksheets/sheet2.xml": `Second part`, + "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, "") + `` diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go index 95e1399f..53d7ebf8 100644 --- a/minipdf-go/xlsx.go +++ b/minipdf-go/xlsx.go @@ -120,9 +120,9 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, targets[relationship.ID] = strings.ToLower(xlsxResolveRelationshipTarget(workbookName, relationship.Target)) } } - packageParts := make(map[string]bool, len(files)) - for name := range files { - packageParts[strings.ToLower(name)] = true + 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) @@ -147,22 +147,25 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, var part string if hasRelationships { part = targets[attrValue(start, "id")] - } else { - // Without workbook relationships, sheets map to parts by position. - part = fmt.Sprintf("xl/worksheets/sheet%d.xml", sheetCount) + } 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 !packageParts[part]: + case !worksheetParts[part]: unresolvedVisibleSheet = true default: visibleParts[part] = true } } - // When every visible sheet resolves to a package 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. + // 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 {