Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
142 changes: 142 additions & 0 deletions minipdf-go/office_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,148 @@ func TestConvertXLSXSkipsEmptyWorksheets(t *testing.T) {
}
}

func TestConvertXLSXSkipsHiddenWorksheets(t *testing.T) {
sheet := func(text string) string {
return `<?xml version="1.0"?><worksheet><sheetData><row r="1">` +
`<c r="A1" t="inlineStr"><is><t>` + text + `</t></is></c></row></sheetData></worksheet>`
}
input := officePackageBytes(t, map[string]string{
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
`<sheet name="Report" sheetId="1" r:id="rId1"/>` +
`<sheet name="Lookup" sheetId="2" state="hidden" r:id="rId2"/>` +
`<sheet name="Internal" sheetId="3" state="veryHidden" r:id="rId3"/>` +
`<sheet name="Summary" sheetId="4" state="visible" r:id="rId4"/>` +
`</sheets></workbook>`,
"xl/_rels/workbook.xml.rels": `<?xml version="1.0"?><Relationships>` +
`<Relationship Id="rId1" Type="worksheet" Target="worksheets/sheet1.xml"/>` +
`<Relationship Id="rId2" Type="worksheet" Target="worksheets/data.xml"/>` +
`<Relationship Id="rId3" Type="worksheet" Target="/xl/worksheets/sheet3.xml"/>` +
`<Relationship Id="rId4" Type="worksheet" Target="worksheets/sheet2.xml"/>` +
`</Relationships>`,
"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 = `<sheet name="A" sheetId="1" r:id="rId1"/>`
hiddenA = `<sheet name="A" sheetId="1" state="hidden" r:id="rId1"/>`
hiddenB = `<sheet name="B" sheetId="2" state="hidden" r:id="rId2"/>`
visibleB = `<sheet name="B" sheetId="2" r:id="rId2"/>`
first = `<Relationship Id="rId1" Type="worksheet" Target="worksheets/sheet1.xml"/>`
second = `<Relationship Id="rId2" Type="worksheet" Target="worksheets/sheet2.xml"/>`
)
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{`<Relationship Id="rId1" Type="worksheet" Target="workbook.xml"/>`, 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 + `<sheet name="B" sheetId="2" state="hidden" r:id="rId9"/>`,
relationships: []string{`<Relationship Id="rId1" Type="worksheet" Target="worksheets/sheet2.xml"/>`},
present: []string{"Second part"}, absent: []string{"First part"}, pages: 1,
},
{
name: "visible sheet with a dangling relationship id is kept",
sheets: `<sheet name="A" sheetId="1" r:id="rId9"/>` + 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{`<Relationship Id="rId1" Type="worksheet" Target="worksheets/missing.xml"/>`, 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, `<Relationship Id="rId2" Type="worksheet" Target="worksheets/Sheet2.xml"/>`},
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": `<?xml version="1.0"?><workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
test.sheets + `</sheets></workbook>`,
"xl/worksheets/" + parts[0]: `<?xml version="1.0"?><worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>First part</t></is></c></row></sheetData></worksheet>`,
"xl/worksheets/" + parts[1]: `<?xml version="1.0"?><worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Second part</t></is></c></row></sheetData></worksheet>`,
}
if test.relationships != nil {
entries["xl/_rels/workbook.xml.rels"] = `<?xml version="1.0"?><Relationships>` + strings.Join(test.relationships, "") + `</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",
Expand Down
95 changes: 95 additions & 0 deletions minipdf-go/xlsx.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/xml"
"fmt"
"io"
"strconv"
"strings"
)
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading