From a5b421504565b4c3aed0800f0f316b0138032465 Mon Sep 17 00:00:00 2001 From: henry3260 Date: Thu, 12 Nov 2026 22:05:28 +0800 Subject: [PATCH 1/3] 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/3] 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 { From 552ef74d4ba8d19fce7f1d4a07f01f531ae09fb9 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Fri, 13 Nov 2026 19:56:18 +0800 Subject: [PATCH 3/3] fix(go): honor XLSX print layout and source styling Respect print areas, pagination, repeat titles, and worksheet visibility. Restore theme and conditional fills, source font metrics, cropped pictures, printed number formats, and page footers. Add focused regression coverage and document supported behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- FEATURE_PARITY.md | 14 +- minipdf-go/README.md | 59 +++- minipdf-go/font.go | 17 +- minipdf-go/office.go | 27 +- minipdf-go/office_test.go | 132 +++++++- minipdf-go/pdf.go | 85 ++++- minipdf-go/pdf_image.go | 53 ++++ minipdf-go/xlsx.go | 113 ++++++- minipdf-go/xlsx_chart.go | 66 +++- minipdf-go/xlsx_color.go | 212 +++++++++++++ minipdf-go/xlsx_color_test.go | 119 +++++++ minipdf-go/xlsx_conditional.go | 203 ++++++++++++ minipdf-go/xlsx_conditional_test.go | 221 +++++++++++++ minipdf-go/xlsx_font.go | 96 ++++++ minipdf-go/xlsx_footer.go | 190 ++++++++++++ minipdf-go/xlsx_layout_test.go | 269 ++++++++++++++++ minipdf-go/xlsx_number.go | 192 ++++++++++++ minipdf-go/xlsx_picture.go | 122 ++++++++ minipdf-go/xlsx_print.go | 240 ++++++++++++++ minipdf-go/xlsx_print_test.go | 254 +++++++++++++++ minipdf-go/xlsx_render.go | 466 +++++++++++++++++++++++----- 21 files changed, 3008 insertions(+), 142 deletions(-) create mode 100644 minipdf-go/pdf_image.go create mode 100644 minipdf-go/xlsx_color.go create mode 100644 minipdf-go/xlsx_color_test.go create mode 100644 minipdf-go/xlsx_conditional.go create mode 100644 minipdf-go/xlsx_conditional_test.go create mode 100644 minipdf-go/xlsx_font.go create mode 100644 minipdf-go/xlsx_footer.go create mode 100644 minipdf-go/xlsx_layout_test.go create mode 100644 minipdf-go/xlsx_number.go create mode 100644 minipdf-go/xlsx_picture.go create mode 100644 minipdf-go/xlsx_print.go create mode 100644 minipdf-go/xlsx_print_test.go diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 96e23432..671deadb 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -173,17 +173,17 @@ spaces consistently for wrapping, run positioning, alignment, and run shading. | Feature | .NET | Rust | Java | Go | Python | Node.js | |---|---:|---:|---:|---:|---:|---:| | Shared/inline strings and scalar values | I | I | I | I | I | B | -| Number/date format rendering | I | P | P | M | M | B | +| Number/date format rendering | I | P | P | P | M | B | | Formula cached values | I | P | P | M | M | B | -| Fonts, fills, borders, alignment | I | P | P | M | M | B | +| Fonts, fills, borders, alignment | I | P | P | P | 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 | P | M | B | -| Print area and print titles | I | P | P | M | M | B | +| Print area and print titles | I | P | P | P | M | B | | Paper size, margins, orientation | I | P | P | P | M | B | -| Fit-to-page and print scale | I | P | P | M | M | B | -| Conditional and table styles | P | P | P | M | M | B | -| Raster images | I | P | P | M | M | B | +| Fit-to-page and print scale | I | P | P | P | M | B | +| Conditional and table styles | P | P | P | P | M | B | +| Raster images | I | P | P | P | M | B | | VML/vector drawings | I | P | P | M | M | B | | Charts | P | M | M | M | M | B/M | @@ -194,6 +194,8 @@ Primary implementation evidence: - Rust: [`xlsx.rs`](minipdf-rs/crates/minipdf/src/xlsx.rs) - Java: [`PoiXlsxRenderer.java`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/xlsx/PoiXlsxRenderer.java) - Go: [`xlsx.go`](minipdf-go/xlsx.go) + and [`xlsx_print.go`](minipdf-go/xlsx_print.go); explicit print areas select + grid rendering with repeated titles, hidden cells, scaling, and page breaks. - Python: [`xlsx.py`](minipdf-python/src/minipdf/xlsx.py) Java XLSX support is substantially broader than Java DOCX/PPTX support because diff --git a/minipdf-go/README.md b/minipdf-go/README.md index 337c8989..c660d926 100644 --- a/minipdf-go/README.md +++ b/minipdf-go/README.md @@ -124,12 +124,65 @@ minipdf data.xlsx --max-rows 100 --max-columns 12 --landscape | PPTX to PDF | Basic slide text | | PDF output | Dependency-free PDF 1.4 writer | | Page size | Office geometry, A4/Letter presets, or custom points | -| Fonts | Embedded registered TTF fonts with ToUnicode; no subsetting or shaping yet | +| Fonts | Embedded registered TTF fonts with ToUnicode; printed XLSX grids can use installed Windows Calibri/Arial variants | | Input safety | Bounded ZIP entry count, size, expansion ratio, encryption, and path validation | | Interfaces | Go package with file, byte, and stream APIs; native CLI | -The initial renderer deliberately does not claim support for Office styles, -images, tables, charts, themes, formulas, merged cells, or font embedding. +XLSX text output skips entirely empty pages within worksheets containing text, +including empty horizontal column groups and pages from trailing formatted rows. +Blank rows within nonempty pages retain their spacing. Layout-only worksheets +keep their existing blank-page behavior. + +Worksheets with an explicit `_xlnm.Print_Area` use the grid renderer instead of +fixed-width text column groups. This path respects rectangular or whole-row/ +column print ranges, repeated print-title rows and columns, hidden rows and +columns, percentage scaling, fit-to-page mode, manual page breaks, and horizontal/ +vertical print centering. It keeps +formatted empty cells inside the print area, so printable template tables are +not discarded merely because they have no text. Sheets without a print area +retain their previous rendering path. + +Grid styles resolve RGB and workbook theme colors, including HSL luminance +tints, for fonts and solid fills. Printed grids also apply conditional solid +fills for text rules, `ISNUMBER(SEARCH(...))` expressions, and numeric `cellIs` +comparisons. Text searches are case-insensitive and support Excel wildcards; +sheet-local cell references respect relative/absolute addressing. Rule priority +and `stopIfTrue` are honored. + +Printed grids use matching registered font families/variants, or installed +Windows Calibri/Arial regular, bold, italic, and bold-italic TTF files when +available. Actual glyph advance widths and font ascent/descent are used for +alignment and shrink-to-fit. Missing/unrecognized font families keep the +built-in PDF font fallback; non-print-area worksheets retain their previous +font-rendering path. Register variant names such as `Calibri Bold Italic` for +portable source-font matching. Fonts are embedded without subsetting; no font +binaries are distributed with MiniPdf. + +Printed numeric cells support common fixed-decimal, grouped, percentage, General, +and day/month/year date formats, including custom escaped/quoted date literals. +The workbook's 1900/1904 date system is respected. Unsupported number-format +patterns keep the stored value. + +One-/two-cell anchored PNG/JPEG/GIF pictures support source cropping, print +scaling, and repetition inside print-title bands. Images are limited to +25 million pixels and transparency is composited onto white. Negative crops, +external image targets, and general vector drawings are not supported. + +Printed footers support left/center/right sections, font names/sizes, +bold/italic toggles, escaped ampersands, sheet names, and final document page +number/count fields. First/even/odd footer variants and `scaleWithDoc` are +respected. Footer placement currently uses worksheet page margins; separate +`alignWithMargins="0"` geometry and header content are not implemented. +Double cell borders retain their two separate strokes. + +Invalid or unsupported print-range expressions and invalid numeric print +settings return an explicit conversion error, as do invalid theme colors, +tints, conditional range/format metadata, corrupt pictures/fonts, and unsupported +footer control codes. Conditional rules outside the +supported subset retain base styling. Dynamic print-area formulas, general +conditional formulas, conditional font/border changes, table styles, indexed +colors, patterned/gradient fills, general drawings, complex text shaping, and +full Office font/layout fidelity are not supported. ## Development diff --git a/minipdf-go/font.go b/minipdf-go/font.go index efa5f59e..fae73b63 100644 --- a/minipdf-go/font.go +++ b/minipdf-go/font.go @@ -13,12 +13,15 @@ import ( ) type embeddedFont struct { - name string - data []byte - font *sfnt.Font - runeToGlyph map[rune]sfnt.GlyphIndex - glyphToRune map[sfnt.GlyphIndex]rune - objectID int + name string + data []byte + font *sfnt.Font + runeToGlyph map[rune]sfnt.GlyphIndex + glyphToRune map[sfnt.GlyphIndex]rune + objectID int + resource string + advances []float64 + ascent, descent float64 } func prepareEmbeddedFont(pages []*PDFPage) *embeddedFont { @@ -38,7 +41,7 @@ func prepareEmbeddedFont(pages []*PDFPage) *embeddedFont { for _, page := range pages { for _, operation := range page.operations { text, ok := operation.(textOperation) - if !ok || !candidate.canEncode(text.text) { + if !ok || text.embedded != nil || !candidate.canEncode(text.text) { continue } candidate.collect(text.text) diff --git a/minipdf-go/office.go b/minipdf-go/office.go index 13c657eb..f08d3beb 100644 --- a/minipdf-go/office.go +++ b/minipdf-go/office.go @@ -30,10 +30,11 @@ var defaultOfficePackageLimits = officePackageLimits{ } type textPage struct { - lines []string - size PageSize - margins Margins - noWrap bool + lines []string + size PageSize + margins Margins + noWrap bool + skipEmpty bool } func openOfficePackage(input []byte) (officePackage, error) { @@ -137,12 +138,12 @@ func renderTextPages(pages []textPage, options ConversionOptions) []byte { if options.Margins != nil { margins = *options.Margins } - addTextPages(document, sourcePage.lines, pageSize, margins, sourcePage.noWrap) + addTextPages(document, sourcePage.lines, pageSize, margins, sourcePage.noWrap, sourcePage.skipEmpty) } return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}) } -func addTextPages(document *PDFDocument, lines []string, pageSize PageSize, margins Margins, noWrap bool) { +func addTextPages(document *PDFDocument, lines []string, pageSize PageSize, margins Margins, noWrap, skipEmpty bool) { const ( fontSize = 11.0 leading = 15.0 @@ -162,14 +163,22 @@ func addTextPages(document *PDFDocument, lines []string, pageSize PageSize, marg wrapped = append(wrapped, "") } - page := document.AddPage(pageSize.Width, pageSize.Height) + var page *PDFPage + if !skipEmpty { + page = document.AddPage(pageSize.Width, pageSize.Height) + } y := pageSize.Height - margins.Top for _, line := range wrapped { if y < margins.Bottom { - page = document.AddPage(pageSize.Width, pageSize.Height) + page = nil y = pageSize.Height - margins.Top } - page.AddText(line, margins.Left, y, fontSize, PDFColorBlack, false) + if !skipEmpty || strings.TrimSpace(line) != "" { + if page == nil { + page = document.AddPage(pageSize.Width, pageSize.Height) + } + page.AddText(line, margins.Left, y, fontSize, PDFColorBlack, false) + } y -= leading } } diff --git a/minipdf-go/office_test.go b/minipdf-go/office_test.go index d96458e4..2ac3b0d6 100644 --- a/minipdf-go/office_test.go +++ b/minipdf-go/office_test.go @@ -3,6 +3,7 @@ package minipdf import ( "archive/zip" "bytes" + "encoding/xml" "errors" "strings" "testing" @@ -240,7 +241,7 @@ func TestConvertXLSXSkipsHiddenWorksheets(t *testing.T) { return `` + `` + text + `` } - input := officePackageBytes(t, map[string]string{ + entries := map[string]string{ "xl/workbook.xml": `` + `` + `` + @@ -257,17 +258,119 @@ func TestConvertXLSXSkipsHiddenWorksheets(t *testing.T) { "xl/worksheets/data.xml": sheet("Hidden lookup"), "xl/worksheets/sheet3.xml": sheet("Very hidden internal"), "xl/worksheets/sheet2.xml": sheet("Visible summary"), - }) + "xl/worksheets/sheet5.xml": sheet("Orphan worksheet"), + } + + for _, renderer := range []string{"text", "grid"} { + t.Run(renderer, func(t *testing.T) { + if renderer == "grid" { + entries["xl/styles.xml"] = `` + entries["xl/worksheets/sheet1.xml"] = strings.Replace( + entries["xl/worksheets/sheet1.xml"], ``, + `1+1`, 1) + if !needsCalculatedXLSXGrid([]byte(entries["xl/worksheets/sheet1.xml"])) { + t.Fatal("fixture does not select the grid renderer") + } + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, "Visible report", "Visible summary") + for _, text := range []string{"Hidden lookup", "Very hidden internal", "Orphan worksheet"} { + if bytes.Contains(pdf, []byte(text)) { + t.Fatalf("PDF contains excluded worksheet text %q", text) + } + } + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 2 { + t.Fatalf("page count = %d, want 2", pages) + } + }) + } +} - 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) +func TestConvertXLSXSkipsEmptyTextPages(t *testing.T) { + tests := []struct { + name string + cell string + }{ + {name: "empty rows", cell: "A"}, + {name: "empty horizontal groups", cell: "J"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/worksheets/sheet1.xml": `` + + `First` + + `Middle` + + `Last` + + `` + + ``, + }) + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "First", "Middle", "Last") + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 3 { + t.Fatalf("page count = %d, want 3 nonempty pages", pages) + } + }) + } +} + +func TestTextPaginationPreservesSpacingWhenSkippingEmptyPages(t *testing.T) { + lines := make([]string, 510) + lines[0], lines[100], lines[152] = "First", "Middle", "Last" + for _, skipEmpty := range []bool{false, true} { + document := NewPDFDocument() + addTextPages(document, lines, PageSizeA4, Margins{}, true, skipEmpty) + pdf := document.Bytes() + wantPages := 11 + if skipEmpty { + wantPages = 3 } + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != wantPages { + t.Fatalf("skipEmpty=%v: pages = %d, want %d", skipEmpty, pages, wantPages) + } + assertPDFContains(t, pdf, nil, "54 787.89 Td", "54 757.89 Td", "54 712.89 Td") } - if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 2 { - t.Fatalf("page count = %d, want 2", pages) +} + +func TestConvertXLSXRejectsMalformedWorkbookMetadata(t *testing.T) { + tests := []struct { + name string + part string + xml string + }{ + { + name: "workbook", + part: "xl/workbook.xml", + xml: ``, + }, + { + name: "truncated workbook after visible sheet", + part: "xl/workbook.xml", + xml: ``, + }, + { + name: "relationships", + part: "xl/_rels/workbook.xml.rels", + xml: ``, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + entries := map[string]string{ + "xl/workbook.xml": ``, + "xl/worksheets/sheet1.xml": ``, + } + entries[test.part] = test.xml + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPackageErrorContains(t, err, "parse "+test.part+":") + var syntaxError *xml.SyntaxError + if !errors.As(err, &syntaxError) { + t.Fatalf("error = %v, want wrapped XML syntax error", err) + } + if len(pdf) != 0 { + t.Fatal("failed conversion returned PDF data") + } + }) } } @@ -301,6 +404,11 @@ func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { parts: [2]string{"data.xml", "summary.xml"}, present: []string{"Second part"}, absent: []string{"First part"}, pages: 1, }, + { + name: "missing relationships with more sheets than parts", + sheets: visibleA + hiddenB + ``, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, { name: "visible sheet whose target is not a worksheet part is kept", sheets: visibleA + hiddenB, @@ -347,6 +455,12 @@ func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { sheets: hiddenA + hiddenB, absent: []string{"First part", "Second part"}, pages: 1, }, + { + name: "all sheets hidden with non-contiguous parts produce one blank page", + sheets: hiddenA + hiddenB, + parts: [2]string{"sheet2.xml", "sheet3.xml"}, + absent: []string{"First part", "Second part"}, pages: 1, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/minipdf-go/pdf.go b/minipdf-go/pdf.go index fe5ec9d1..fa906f0c 100644 --- a/minipdf-go/pdf.go +++ b/minipdf-go/pdf.go @@ -36,9 +36,10 @@ type PDFSaveOptions struct { } type PDFPage struct { - Width float64 - Height float64 - operations []pdfOperation + Width float64 + Height float64 + operations []pdfOperation + decorations func(int, int) []pdfOperation } func NewPDFDocument() *PDFDocument { @@ -81,16 +82,22 @@ type textOperation struct { horizontalScale float64 color PDFColor bold bool + italic bool + embedded *embeddedFont } func (operation textOperation) appendPDF(buffer *bytes.Buffer, embedded *embeddedFont) { + if operation.embedded != nil { + embedded = operation.embedded + } horizontalScale := "" if operation.horizontalScale != 0 && operation.horizontalScale != 100 { horizontalScale = pdfNumber(operation.horizontalScale) + " Tz " } if embedded != nil { if encoded, ok := embedded.encode(operation.text); ok { - fmt.Fprintf(buffer, "BT /FU1 %s Tf %s%s %s %s rg %s %s Td <%s> Tj ET\n", + fmt.Fprintf(buffer, "BT /%s %s Tf %s%s %s %s rg %s %s Td <%s> Tj ET\n", + embedded.resource, pdfNumber(operation.fontSize), horizontalScale, pdfNumber(operation.color.Red), @@ -107,6 +114,12 @@ func (operation textOperation) appendPDF(buffer *bytes.Buffer, embedded *embedde if operation.bold { font = "F2" } + if operation.italic { + font = "F3" + if operation.bold { + font = "F4" + } + } fmt.Fprintf(buffer, "BT /%s %s Tf %s%s %s %s rg %s %s Td (%s) Tj ET\n", font, pdfNumber(operation.fontSize), @@ -168,13 +181,72 @@ func (document *PDFDocument) BytesWithOptions(options PDFSaveOptions) []byte { } pageCount := len(pages) + decoratedPages := make([]*PDFPage, pageCount) + for index, page := range pages { + clone := *page + clone.operations = append([]pdfOperation(nil), page.operations...) + if page.decorations != nil { + clone.operations = append(clone.operations, page.decorations(index+1, pageCount)...) + } + decoratedPages[index] = &clone + } + pages = decoratedPages objects := make([][]byte, 4) objects[0] = []byte("<< /Type /Catalog /Pages 2 0 R >>") objects[2] = []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") objects[3] = []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") embedded := prepareEmbeddedFont(pages) + var fonts []*embeddedFont if embedded != nil { + embedded.resource = "FU1" embedded.objectID = appendEmbeddedFontObjects(&objects, embedded) + fonts = append(fonts, embedded) + } + seenFonts := make(map[*embeddedFont]*embeddedFont) + hasItalic := false + for _, page := range pages { + for _, operation := range page.operations { + text, ok := operation.(textOperation) + if !ok { + continue + } + hasItalic = hasItalic || text.italic + if text.embedded == nil { + continue + } + canonical := seenFonts[text.embedded] + if canonical == nil { + canonical = text.embedded + for _, existing := range fonts { + if existing.name == canonical.name && bytes.Equal(existing.data, canonical.data) { + canonical = existing + break + } + } + seenFonts[text.embedded] = canonical + if canonical == text.embedded { + fonts = append(fonts, canonical) + } + } + canonical.collect(text.text) + } + } + for index, font := range fonts { + if font == embedded { + continue + } + for alias, canonical := range seenFonts { + alias.resource = canonical.resource + alias.objectID = canonical.objectID + } + font.resource = fmt.Sprintf("FU%d", index+1) + font.objectID = appendEmbeddedFontObjects(&objects, font) + } + italicResources := "" + if hasItalic { + regular := appendPDFObject(&objects, []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding >>")) + bold := appendPDFObject(&objects, []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-BoldOblique /Encoding /WinAnsiEncoding >>")) + italicResources = fmt.Sprintf(" /F3 %d 0 R /F4 %d 0 R", regular, bold) } pageObjectStart := len(objects) + 1 @@ -188,8 +260,9 @@ func (document *PDFDocument) BytesWithOptions(options PDFSaveOptions) []byte { pageObjectNumber := pageObjectStart + index*2 contentObjectNumber := pageObjectNumber + 1 fontResources := "/F1 3 0 R /F2 4 0 R" - if embedded != nil { - fontResources += fmt.Sprintf(" /FU1 %d 0 R", embedded.objectID) + fontResources += italicResources + for _, font := range fonts { + fontResources += fmt.Sprintf(" /%s %d 0 R", font.resource, font.objectID) } pageObject := []byte(fmt.Sprintf( "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] /Resources << /Font << %s >> >> /Contents %d 0 R >>", diff --git a/minipdf-go/pdf_image.go b/minipdf-go/pdf_image.go new file mode 100644 index 00000000..86cd1396 --- /dev/null +++ b/minipdf-go/pdf_image.go @@ -0,0 +1,53 @@ +package minipdf + +import ( + "bytes" + "compress/zlib" + "encoding/hex" + "fmt" + "image" + "image/color" + "image/draw" +) + +type imageOperation struct { + x, y, width, height float64 + pixelWidth, pixelHeight int + data []byte +} + +func newPDFImage(source image.Image, crop image.Rectangle) (imageOperation, error) { + if crop.Empty() || !crop.In(source.Bounds()) { + return imageOperation{}, fmt.Errorf("invalid PDF image crop %v", crop) + } + if int64(crop.Dx()) > 25000000/int64(crop.Dy()) { + return imageOperation{}, fmt.Errorf("PDF image exceeds 25 million pixels") + } + raster := image.NewRGBA(image.Rect(0, 0, crop.Dx(), crop.Dy())) + draw.Draw(raster, raster.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src) + draw.Draw(raster, raster.Bounds(), source, crop.Min, draw.Over) + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + row := make([]byte, raster.Bounds().Dx()*3) + for y := 0; y < raster.Bounds().Dy(); y++ { + for x := 0; x < raster.Bounds().Dx(); x++ { + value := raster.RGBAAt(x, y) + row[x*3], row[x*3+1], row[x*3+2] = value.R, value.G, value.B + } + if _, err := writer.Write(row); err != nil { + return imageOperation{}, err + } + } + if err := writer.Close(); err != nil { + return imageOperation{}, err + } + return imageOperation{pixelWidth: raster.Bounds().Dx(), pixelHeight: raster.Bounds().Dy(), data: compressed.Bytes()}, nil +} + +func (operation imageOperation) appendPDF(buffer *bytes.Buffer, _ *embeddedFont) { + fmt.Fprintf(buffer, "q %s 0 0 %s %s %s cm BI /W %d /H %d /CS /RGB /BPC 8 /F [/ASCIIHexDecode /FlateDecode] ID\n", + pdfNumber(operation.width), pdfNumber(operation.height), pdfNumber(operation.x), pdfNumber(operation.y), + operation.pixelWidth, operation.pixelHeight) + buffer.WriteString(hex.EncodeToString(operation.data)) + buffer.WriteString(">\nEI Q\n") +} diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go index 53d7ebf8..ec250980 100644 --- a/minipdf-go/xlsx.go +++ b/minipdf-go/xlsx.go @@ -22,11 +22,11 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { if len(worksheets) == 0 { return nil, errorsNewMissingWorksheets() } - worksheets, err = visibleXLSXWorksheets(files, worksheets) + worksheets, printSettings, err := visibleXLSXWorksheets(files, worksheets) if err != nil { return nil, err } - styles, err := readXLSXStyles(files) + styles, differentialFills, err := readXLSXStyles(files) if err != nil { return nil, err } @@ -43,12 +43,18 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { } } if needsGridRendering { - return renderXLSXWorksheets(files, worksheets, sharedStrings, styles, options) + return renderXLSXWorksheets(files, worksheets, sharedStrings, styles, differentialFills, options, printSettings) } } - pages := make([]textPage, 0, len(worksheets)) + document := NewPDFDocument() fallbackPageSize := PageSizeA4 for _, name := range worksheets { + if settings := printSettings[strings.ToLower(name)]; len(settings.areas) > 0 { + if err := renderXLSXWorksheet(document, files, name, sharedStrings, styles, differentialFills, options, settings); err != nil { + return nil, err + } + continue + } worksheetXML, readErr := files.read(name) if readErr != nil { return nil, readErr @@ -72,6 +78,13 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { if options.PageSize != nil { effectivePageSize = *options.PageSize } + hasText := false + for _, line := range lines { + if strings.TrimSpace(line) != "" { + hasText = true + break + } + } for _, group := range splitWorksheetColumnGroups(lines, 9) { overflowPages := [][]string{group} noWrap := false @@ -81,27 +94,30 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { } for _, overflowPage := range overflowPages { overflowPage = append([]string{""}, overflowPage...) - pages = append(pages, textPage{lines: overflowPage, size: pageSize, noWrap: noWrap}) + addTextPages(document, overflowPage, effectivePageSize, Margins{}, noWrap, hasText) } } } - if len(pages) == 0 { - pages = append(pages, textPage{size: fallbackPageSize}) + if len(document.pages) == 0 { + if options.PageSize != nil { + fallbackPageSize = *options.PageSize + } + document.AddPage(fallbackPageSize.Width, fallbackPageSize.Height) } - return renderTextPages(pages, options), nil + return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}), 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) { +func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, map[string]xlsxPrintSettings, error) { const workbookName = "xl/workbook.xml" if _, ok := files[workbookName]; !ok { - return worksheets, nil + return worksheets, nil, nil } data, err := files.read(workbookName) if err != nil { - return nil, err + return nil, nil, err } // Part names are case-insensitive, so targets are compared in lower case. targets := make(map[string]string) @@ -110,11 +126,11 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, if hasRelationships { relationshipsData, readErr := files.read(relationshipsName) if readErr != nil { - return nil, readErr + return nil, nil, readErr } var relationships xlsxRelationshipsDocument if err := xml.Unmarshal(relationshipsData, &relationships); err != nil { - return nil, fmt.Errorf("parse %s: %w", relationshipsName, err) + return nil, nil, fmt.Errorf("parse %s: %w", relationshipsName, err) } for _, relationship := range relationships.Items { targets[relationship.ID] = strings.ToLower(xlsxResolveRelationshipTarget(workbookName, relationship.Target)) @@ -127,6 +143,10 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, visibleParts := make(map[string]bool) hiddenParts := make(map[string]bool) sheetCount := 0 + var sheetParts []string + settingsByIndex := make(map[int]xlsxPrintSettings) + var sheetNames []string + date1904 := false unresolvedVisibleSheet := false decoder := xml.NewDecoder(bytes.NewReader(data)) for { @@ -135,13 +155,60 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, break } if tokenErr != nil { - return nil, fmt.Errorf("parse %s: %w", workbookName, tokenErr) + return nil, nil, fmt.Errorf("parse %s: %w", workbookName, tokenErr) } start, ok := token.(xml.StartElement) - if !ok || start.Name.Local != "sheet" { + if !ok { + continue + } + if start.Name.Local == "workbookPr" { + if value := attrValue(start, "date1904"); value != "" { + date1904, err = strconv.ParseBool(value) + if err != nil { + return nil, nil, fmt.Errorf("parse %s: invalid date1904 %q", workbookName, value) + } + } + } + if start.Name.Local == "definedName" { + name := attrValue(start, "name") + if name != "_xlnm.Print_Area" && name != "_xlnm.Print_Titles" { + continue + } + index, indexErr := strconv.Atoi(attrValue(start, "localSheetId")) + if indexErr != nil || index < 0 { + return nil, nil, fmt.Errorf("parse %s: invalid localSheetId for %s", workbookName, name) + } + var value string + if err := decoder.DecodeElement(&value, &start); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", workbookName, err) + } + ranges, rangeErr := parseXLSXPrintRanges(value) + if rangeErr != nil { + return nil, nil, fmt.Errorf("parse %s %s: %w", workbookName, name, rangeErr) + } + settings := settingsByIndex[index] + if name == "_xlnm.Print_Area" { + settings.areas = ranges + } else { + for _, area := range ranges { + switch { + case area.startColumn == 1 && area.endColumn == 16384: + settings.rows = xlsxRange{start: area.startRow, end: area.endRow} + case area.startRow == 1 && area.endRow == 1048576: + settings.columns = xlsxRange{start: area.startColumn, end: area.endColumn} + default: + return nil, nil, fmt.Errorf("parse %s: print titles must be whole rows or columns", workbookName) + } + } + } + settingsByIndex[index] = settings + continue + } + if start.Name.Local != "sheet" { continue } sheetCount++ + sheetNames = append(sheetNames, attrValue(start, "name")) state := attrValue(start, "state") isHidden := state == "hidden" || state == "veryHidden" var part string @@ -152,6 +219,7 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, // worksheet parts by position. part = strings.ToLower(worksheets[sheetCount-1]) } + sheetParts = append(sheetParts, part) switch { case isHidden: hiddenParts[part] = true @@ -178,7 +246,20 @@ func visibleXLSXWorksheets(files officePackage, worksheets []string) ([]string, visible = append(visible, name) } } - return visible, nil + printSettings := make(map[string]xlsxPrintSettings) + for index, settings := range settingsByIndex { + settings.date1904 = date1904 + if index < len(sheetNames) { + settings.sheetName = sheetNames[index] + } + if index >= len(sheetParts) { + return nil, nil, fmt.Errorf("parse %s: print settings refer to missing sheet %d", workbookName, index) + } + if part := sheetParts[index]; worksheetParts[part] { + printSettings[part] = settings + } + } + return visible, printSettings, nil } func limitWorksheet(lines []string, maxRows, maxColumns int) []string { diff --git a/minipdf-go/xlsx_chart.go b/minipdf-go/xlsx_chart.go index 934b7338..9e5b9e3a 100644 --- a/minipdf-go/xlsx_chart.go +++ b/minipdf-go/xlsx_chart.go @@ -29,9 +29,10 @@ type xlsxChart struct { type xlsxRelationshipsDocument struct { Items []struct { - ID string `xml:"Id,attr"` - Type string `xml:"Type,attr"` - Target string `xml:"Target,attr"` + ID string `xml:"Id,attr"` + Type string `xml:"Type,attr"` + Target string `xml:"Target,attr"` + TargetMode string `xml:"TargetMode,attr"` } `xml:"Relationship"` } @@ -54,6 +55,22 @@ type xlsxChartAnchor struct { ID string `xml:"id,attr"` } `xml:"graphic>graphicData>chart"` } `xml:"graphicFrame"` + Picture *struct { + Fill struct { + Blip struct { + ID string `xml:"embed,attr"` + } `xml:"blip"` + Crop struct { + Left int `xml:"l,attr"` + Right int `xml:"r,attr"` + Top int `xml:"t,attr"` + Bottom int `xml:"b,attr"` + } `xml:"srcRect"` + } `xml:"blipFill"` + } `xml:"pic"` + Client struct { + Print *bool `xml:"fPrintsWithSheet,attr"` + } `xml:"clientData"` } type xlsxDrawingDocument struct { @@ -98,19 +115,19 @@ type xlsxChartDocument struct { } `xml:"chart"` } -func readXLSXCharts(files officePackage, worksheetName string, grid *xlsxGrid) ([]xlsxChart, error) { +func readXLSXDrawing(files officePackage, worksheetName string) (string, xlsxDrawingDocument, error) { relationshipsName := xlsxRelationshipPartName(worksheetName) _, ok := files[relationshipsName] if !ok { - return nil, nil + return "", xlsxDrawingDocument{}, nil } relationshipsData, err := files.read(relationshipsName) if err != nil { - return nil, err + return "", xlsxDrawingDocument{}, err } var relationships xlsxRelationshipsDocument if err := xml.Unmarshal(relationshipsData, &relationships); err != nil { - return nil, fmt.Errorf("parse %s: %w", relationshipsName, err) + return "", xlsxDrawingDocument{}, fmt.Errorf("parse %s: %w", relationshipsName, err) } drawingName := "" for _, relationship := range relationships.Items { @@ -120,13 +137,36 @@ func readXLSXCharts(files officePackage, worksheetName string, grid *xlsxGrid) ( } } if drawingName == "" { - return nil, nil + return "", xlsxDrawingDocument{}, nil } drawingData, err := files.read(drawingName) + if err != nil { + return "", xlsxDrawingDocument{}, err + } + var drawing xlsxDrawingDocument + if err := xml.Unmarshal(drawingData, &drawing); err != nil { + return "", xlsxDrawingDocument{}, fmt.Errorf("parse %s: %w", drawingName, err) + } + return drawingName, drawing, nil +} + +func readXLSXCharts(files officePackage, worksheetName string, grid *xlsxGrid) ([]xlsxChart, error) { + drawingName, drawing, err := readXLSXDrawing(files, worksheetName) if err != nil { return nil, err } + anchors := append(drawing.OneCell, drawing.TwoCell...) + hasChart := false + for _, anchor := range anchors { + if anchor.Frame.Reference.ID != "" { + hasChart = true + break + } + } + if !hasChart { + return nil, nil + } drawingRelationshipsName := xlsxRelationshipPartName(drawingName) drawingRelationshipsData, err := files.read(drawingRelationshipsName) if err != nil { @@ -143,11 +183,6 @@ func readXLSXCharts(files officePackage, worksheetName string, grid *xlsxGrid) ( } } - var drawing xlsxDrawingDocument - if err := xml.Unmarshal(drawingData, &drawing); err != nil { - return nil, fmt.Errorf("parse %s: %w", drawingName, err) - } - anchors := append(drawing.OneCell, drawing.TwoCell...) charts := make([]xlsxChart, 0, len(anchors)) for _, anchor := range anchors { chartName := chartParts[anchor.Frame.Reference.ID] @@ -343,6 +378,11 @@ func renderXLSXCharts(page *PDFPage, grid xlsxGrid, rows, columns xlsxRange) { bottom: grid.pageSize.Height - grid.margins.Top - bandHeight, } for _, chart := range grid.charts { + scale := grid.layoutScale() + chart.columnOffset *= scale + chart.rowOffset *= scale + chart.width *= scale + chart.height *= scale chartLeft := grid.columnOffset(chart.anchorColumn) + chart.columnOffset chartTop := grid.rowOffset(chart.anchorRow) + chart.rowOffset if chartLeft >= bandLeft+bandWidth || chartLeft+chart.width <= bandLeft || diff --git a/minipdf-go/xlsx_color.go b/minipdf-go/xlsx_color.go new file mode 100644 index 00000000..1c8bbac8 --- /dev/null +++ b/minipdf-go/xlsx_color.go @@ -0,0 +1,212 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "math" + "strings" +) + +type xlsxColorXML struct { + RGB string `xml:"rgb,attr"` + Theme *int `xml:"theme,attr"` + Tint float64 `xml:"tint,attr"` +} + +type xlsxFillXML struct { + Pattern struct { + Type string `xml:"patternType,attr"` + Foreground xlsxColorXML `xml:"fgColor"` + Background xlsxColorXML `xml:"bgColor"` + } `xml:"patternFill"` +} + +func readXLSXTheme(files officePackage) (map[int]PDFColor, error) { + names := []string{"lt1", "dk1", "lt2", "dk2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"} + defaults := []string{"FFFFFF", "000000", "EEECE1", "1F497D", "4F81BD", "C0504D", "9BBB59", "8064A2", "4BACC6", "F79646", "0000FF", "800080"} + themeName := "xl/theme/theme1.xml" + const relationshipsName = "xl/_rels/workbook.xml.rels" + hasThemeRelationship := false + if _, ok := files[relationshipsName]; ok { + data, err := files.read(relationshipsName) + if err != nil { + return nil, err + } + var relationships xlsxRelationshipsDocument + if err := xml.Unmarshal(data, &relationships); err != nil { + return nil, fmt.Errorf("parse %s: %w", relationshipsName, err) + } + for _, relationship := range relationships.Items { + if strings.HasSuffix(relationship.Type, "/theme") { + if relationship.TargetMode == "External" || strings.TrimSpace(relationship.Target) == "" { + return nil, fmt.Errorf("invalid XLSX theme relationship target %q", relationship.Target) + } + themeName = xlsxResolveRelationshipTarget("xl/workbook.xml", relationship.Target) + hasThemeRelationship = true + break + } + } + } + actualName := "" + for name := range files { + if strings.EqualFold(name, themeName) { + actualName = name + break + } + } + if actualName == "" { + if hasThemeRelationship { + return nil, fmt.Errorf("Office package theme part %q is missing", themeName) + } + colors := make(map[int]PDFColor, len(defaults)) + for index, value := range defaults { + colors[index], _ = parseXLSXColor(value) + } + return colors, nil + } + data, err := files.read(actualName) + if err != nil { + return nil, err + } + colors := make(map[int]PDFColor) + decoder := xml.NewDecoder(bytes.NewReader(data)) + inScheme := false + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("parse %s: %w", actualName, err) + } + if end, ok := token.(xml.EndElement); ok && end.Name.Local == "clrScheme" { + inScheme = false + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local == "clrScheme" { + inScheme = true + continue + } + if !inScheme { + continue + } + for index, name := range names { + if start.Name.Local != name { + continue + } + var entry struct { + RGB *struct { + Value string `xml:"val,attr"` + } `xml:"srgbClr"` + System *struct { + Value string `xml:"val,attr"` + Last string `xml:"lastClr,attr"` + } `xml:"sysClr"` + } + if err := decoder.DecodeElement(&entry, &start); err != nil { + return nil, fmt.Errorf("parse %s: %w", actualName, err) + } + value := "" + switch { + case entry.RGB != nil: + value = entry.RGB.Value + case entry.System != nil: + value = entry.System.Last + if value == "" { + switch entry.System.Value { + case "window": + value = "FFFFFF" + case "windowText": + value = "000000" + } + } + } + color, ok := parseXLSXColor(value) + if !ok { + return nil, fmt.Errorf("parse %s: unsupported or invalid theme color %s", actualName, name) + } + colors[index] = color + break + } + } + return colors, nil +} + +func resolveXLSXColor(source xlsxColorXML, theme map[int]PDFColor) (*PDFColor, error) { + if math.IsNaN(source.Tint) || math.IsInf(source.Tint, 0) || source.Tint < -1 || source.Tint > 1 { + return nil, fmt.Errorf("invalid XLSX color tint %v", source.Tint) + } + var color PDFColor + switch { + case source.Theme != nil: + var ok bool + color, ok = theme[*source.Theme] + if !ok { + return nil, fmt.Errorf("unknown XLSX theme color index %d", *source.Theme) + } + case source.RGB != "": + var ok bool + color, ok = parseXLSXColor(source.RGB) + if !ok { + return nil, fmt.Errorf("invalid XLSX RGB color %q", source.RGB) + } + default: + return nil, nil + } + if source.Tint != 0 { + color = tintXLSXColor(color, source.Tint) + } + return &color, nil +} + +func tintXLSXColor(color PDFColor, tint float64) PDFColor { + minimum := min(color.Red, color.Green, color.Blue) + maximum := max(color.Red, color.Green, color.Blue) + luminance := (minimum + maximum) / 2 + chroma := maximum - minimum + saturation, hue := 0.0, 0.0 + if chroma > 0 { + saturation = chroma / (1 - math.Abs(2*luminance-1)) + switch maximum { + case color.Red: + hue = math.Mod((color.Green-color.Blue)/chroma+6, 6) + case color.Green: + hue = (color.Blue-color.Red)/chroma + 2 + case color.Blue: + hue = (color.Red-color.Green)/chroma + 4 + } + } + // Excel tint changes HSL luminance, not the individual RGB channels. + if tint < 0 { + luminance *= 1 + tint + } else { + luminance = luminance*(1-tint) + tint + } + chroma = (1 - math.Abs(2*luminance-1)) * saturation + second := chroma * (1 - math.Abs(math.Mod(hue, 2)-1)) + offset := luminance - chroma/2 + red, green, blue := 0.0, 0.0, 0.0 + switch { + case hue < 1: + red, green = chroma, second + case hue < 2: + red, green = second, chroma + case hue < 3: + green, blue = chroma, second + case hue < 4: + green, blue = second, chroma + case hue < 5: + red, blue = second, chroma + default: + red, blue = chroma, second + } + return PDFColor{ + Red: math.Round((red+offset)*255) / 255, Green: math.Round((green+offset)*255) / 255, + Blue: math.Round((blue+offset)*255) / 255, + } +} diff --git a/minipdf-go/xlsx_color_test.go b/minipdf-go/xlsx_color_test.go new file mode 100644 index 00000000..8a2a244d --- /dev/null +++ b/minipdf-go/xlsx_color_test.go @@ -0,0 +1,119 @@ +package minipdf + +import ( + "math" + "strings" + "testing" +) + +func xlsxStylePackage(t *testing.T, entries map[string]string) officePackage { + t.Helper() + files, err := openOfficePackage(officePackageBytes(t, entries)) + if err != nil { + t.Fatal(err) + } + return files +} + +func TestXLSXThemeColorsAndTint(t *testing.T) { + theme, err := readXLSXTheme(xlsxStylePackage(t, map[string]string{"xl/styles.xml": ""})) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + index int + tint float64 + want string + }{ + {0, 0, "FFFFFF"}, {1, 0, "000000"}, {2, 0, "EEECE1"}, {3, 0, "1F497D"}, + {6, 0.6, "D7E4BD"}, {6, -0.5, "4F6228"}, {0, -0.5, "808080"}, + {1, 0.5, "808080"}, {4, -1, "000000"}, {4, 1, "FFFFFF"}, + } { + color, err := resolveXLSXColor(xlsxColorXML{Theme: &test.index, Tint: test.tint}, theme) + want, _ := parseXLSXColor(test.want) + if err != nil || color == nil || *color != want { + t.Errorf("theme %d tint %g = %v, %v; want %s", test.index, test.tint, color, err, test.want) + } + } + color, err := resolveXLSXColor(xlsxColorXML{RGB: "FF92D050"}, theme) + want, _ := parseXLSXColor("92D050") + if err != nil || color == nil || *color != want { + t.Fatalf("RGB = %v, %v; want %v", color, err, want) + } + for _, index := range []int{-1, 12} { + if _, err := resolveXLSXColor(xlsxColorXML{Theme: &index}, theme); err == nil { + t.Errorf("invalid theme index %d accepted", index) + } + } + for _, tint := range []float64{-1.1, 1.1, math.NaN(), math.Inf(1)} { + if _, err := resolveXLSXColor(xlsxColorXML{RGB: "FFFFFF", Tint: tint}, theme); err == nil { + t.Errorf("invalid tint %v accepted", tint) + } + } + if _, err := resolveXLSXColor(xlsxColorXML{RGB: "invalid"}, theme); err == nil { + t.Fatal("invalid RGB accepted") + } + for _, test := range []struct{ source, want string }{ + {"FF0000", "FF8080"}, {"FFFF00", "FFFF80"}, {"00FF00", "80FF80"}, + {"00FFFF", "80FFFF"}, {"0000FF", "8080FF"}, {"FF00FF", "FF80FF"}, + } { + color, err := resolveXLSXColor(xlsxColorXML{RGB: test.source, Tint: 0.5}, theme) + want, _ := parseXLSXColor(test.want) + if err != nil || color == nil || *color != want { + t.Errorf("RGB %s tint 0.5 = %v, %v; want %s", test.source, color, err, test.want) + } + } +} + +func TestXLSXStylesUseRelatedThemeForFontsFillsAndDifferentialFills(t *testing.T) { + entries := map[string]string{ + "xl/_rels/workbook.xml.rels": ``, + "xl/theme/custom.xml": `` + + `` + + `` + + ``, + "xl/styles.xml": `` + + `` + + `` + + ``, + } + styles, fills, err := readXLSXStyles(xlsxStylePackage(t, entries)) + if err != nil { + t.Fatal(err) + } + font, _ := parseXLSXColor("123456") + fill, _ := parseXLSXColor("D7E4BD") + if len(styles) != 1 || styles[0].font.color != font || styles[0].fill == nil || *styles[0].fill != fill { + t.Fatalf("styles = %#v", styles) + } + if len(fills) != 1 || fills[0] == nil || *fills[0] != PDFColorWhite { + t.Fatalf("differential fills = %#v", fills) + } + for _, test := range []struct { + name, part, value, want string + }{ + {"missing target", "xl/_rels/workbook.xml.rels", ``, "missing"}, + {"external target", "xl/_rels/workbook.xml.rels", ``, "invalid"}, + {"malformed theme", "xl/theme/custom.xml", "", "parse"}, + {"missing indexed theme entry", "xl/theme/custom.xml", "", "unknown"}, + {"invalid theme color", "xl/theme/custom.xml", ``, "invalid"}, + } { + t.Run(test.name, func(t *testing.T) { + changed := make(map[string]string, len(entries)) + for name, value := range entries { + changed[name] = value + } + changed[test.part] = test.value + _, _, err := readXLSXStyles(xlsxStylePackage(t, changed)) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + entries["xl/theme/custom.xml"] = strings.ReplaceAll(entries["xl/theme/custom.xml"], ` lastClr="123456"`, "") + entries["xl/theme/custom.xml"] = strings.ReplaceAll(entries["xl/theme/custom.xml"], ` lastClr="FFFFFF"`, "") + fallbackStyles, fallbackFills, err := readXLSXStyles(xlsxStylePackage(t, entries)) + if err != nil || fallbackStyles[0].font.color != PDFColorBlack || *fallbackFills[0] != PDFColorWhite { + t.Fatalf("system-color fallback = %#v, %#v, %v", fallbackStyles, fallbackFills, err) + } +} diff --git a/minipdf-go/xlsx_conditional.go b/minipdf-go/xlsx_conditional.go new file mode 100644 index 00000000..fc9a3963 --- /dev/null +++ b/minipdf-go/xlsx_conditional.go @@ -0,0 +1,203 @@ +package minipdf + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +type xlsxConditionalFormat struct { + References string `xml:"sqref,attr"` + Rules []xlsxConditionalRule `xml:"cfRule"` + areas []xlsxMerge +} + +type xlsxConditionalRule struct { + Type string `xml:"type,attr"` + Operator string `xml:"operator,attr"` + Text string `xml:"text,attr"` + Format *int `xml:"dxfId,attr"` + Priority int `xml:"priority,attr"` + StopIfTrue bool `xml:"stopIfTrue,attr"` + Formulas []string `xml:"formula"` +} + +var xlsxConditionalSearch = regexp.MustCompile(`(?i)^\s*ISNUMBER\(\s*SEARCH\(\s*"((?:[^"]|"")*)"\s*,\s*(\$?[A-Z]+\$?[1-9][0-9]*)\s*\)\s*\)\s*(?:=\s*TRUE(?:\(\))?)?\s*$`) +var xlsxConditionalReference = regexp.MustCompile(`(?i)^(\$?)([A-Z]+)(\$?)([1-9][0-9]*)$`) + +func (grid *xlsxGrid) applyConditionalFills(styles []xlsxStyle, fills []*PDFColor, scopes []xlsxMerge) ([]xlsxStyle, error) { + type locatedRule struct { + rule xlsxConditionalRule + format xlsxConditionalFormat + } + var rules []locatedRule + for _, format := range grid.conditionalFills { + for _, rule := range format.Rules { + if rule.Priority <= 0 { + return nil, fmt.Errorf("invalid XLSX conditional formatting priority %d", rule.Priority) + } + if rule.Format != nil && (*rule.Format < 0 || *rule.Format >= len(fills)) { + return nil, fmt.Errorf("unknown XLSX differential format index %d", *rule.Format) + } + rules = append(rules, locatedRule{rule, format}) + } + } + sort.SliceStable(rules, func(i, j int) bool { return rules[i].rule.Priority < rules[j].rule.Priority }) + implicitCells := make(map[[2]int]bool) + for _, format := range grid.conditionalFills { + for _, area := range format.areas { + for _, scope := range scopes { + for row := max(area.startRow, scope.startRow); row <= min(area.endRow, scope.endRow); row++ { + for column := max(area.startColumn, scope.startColumn); column <= min(area.endColumn, scope.endColumn); column++ { + key := [2]int{row, column} + if _, exists := grid.cells[key]; !exists { + grid.cells[key] = xlsxCell{row: row, column: column, numeric: true} + implicitCells[key] = true + } + } + } + } + } + } + for key, cell := range grid.cells { + var fill *PDFColor + for _, located := range rules { + inRange := false + for _, area := range located.format.areas { + if cell.row >= area.startRow && cell.row <= area.endRow && cell.column >= area.startColumn && cell.column <= area.endColumn { + inRange = true + break + } + } + if !inRange || !grid.matchesConditionalRule(located.rule, cell, located.format.areas[0]) { + continue + } + if fill == nil && located.rule.Format != nil { + fill = fills[*located.rule.Format] + } + if located.rule.StopIfTrue { + break + } + } + if fill != nil { + style := styleAt(styles, cell.style) + style.fill = fill + cell.style = len(styles) + styles = append(styles, style) + grid.cells[key] = cell + } else if implicitCells[key] { + delete(grid.cells, key) + } + } + return styles, nil +} + +func (grid *xlsxGrid) matchesConditionalRule(rule xlsxConditionalRule, cell xlsxCell, origin xlsxMerge) bool { + switch rule.Type { + case "containsText": + return matchesXLSXSearch(rule.Text, cell.value) + case "notContainsText": + return !matchesXLSXSearch(rule.Text, cell.value) + case "beginsWith": + return strings.HasPrefix(strings.ToLower(cell.value), strings.ToLower(rule.Text)) + case "endsWith": + return strings.HasSuffix(strings.ToLower(cell.value), strings.ToLower(rule.Text)) + case "expression": + if len(rule.Formulas) != 1 { + return false + } + match := xlsxConditionalSearch.FindStringSubmatch(rule.Formulas[0]) + if match == nil { + return false + } + key, ok := conditionalXLSXReference(match[2], cell, origin) + return ok && matchesXLSXSearch(strings.ReplaceAll(match[1], `""`, `"`), grid.cells[key].value) + case "cellIs": + if len(rule.Formulas) == 0 || !cell.numeric { + return false + } + value, ok := grid.evaluateCell([2]int{cell.row, cell.column}, make(map[[2]int]bool)) + if !ok { + return false + } + bounds := make([]float64, len(rule.Formulas)) + for index, formula := range rule.Formulas { + if key, ok := conditionalXLSXReference(strings.TrimSpace(formula), cell, origin); ok { + bounds[index], ok = grid.evaluateCell(key, make(map[[2]int]bool)) + if !ok { + return false + } + } else { + bound, err := strconv.ParseFloat(strings.TrimSpace(formula), 64) + if err != nil { + return false + } + bounds[index] = bound + } + } + switch rule.Operator { + case "equal": + return len(bounds) == 1 && value == bounds[0] + case "notEqual": + return len(bounds) == 1 && value != bounds[0] + case "lessThan": + return len(bounds) == 1 && value < bounds[0] + case "lessThanOrEqual": + return len(bounds) == 1 && value <= bounds[0] + case "greaterThan": + return len(bounds) == 1 && value > bounds[0] + case "greaterThanOrEqual": + return len(bounds) == 1 && value >= bounds[0] + case "between": + return len(bounds) == 2 && value >= bounds[0] && value <= bounds[1] + case "notBetween": + return len(bounds) == 2 && (value < bounds[0] || value > bounds[1]) + } + } + // Other valid conditional-rule formulas retain the existing base style. + return false +} + +func matchesXLSXSearch(pattern, value string) bool { + if !strings.ContainsAny(pattern, "*?~") { + return strings.Contains(strings.ToLower(value), strings.ToLower(pattern)) + } + var expression strings.Builder + expression.WriteString("(?is)") + characters := []rune(pattern) + for index := 0; index < len(characters); index++ { + switch character := characters[index]; character { + case '*': + expression.WriteString(".*") + case '?': + expression.WriteByte('.') + case '~': + if index+1 < len(characters) && strings.ContainsRune("*?~", characters[index+1]) { + index++ + expression.WriteString(regexp.QuoteMeta(string(characters[index]))) + } else { + expression.WriteByte('~') + } + default: + expression.WriteString(regexp.QuoteMeta(string(character))) + } + } + return regexp.MustCompile(expression.String()).MatchString(value) +} + +func conditionalXLSXReference(reference string, cell xlsxCell, origin xlsxMerge) ([2]int, bool) { + match := xlsxConditionalReference.FindStringSubmatch(reference) + if match == nil { + return [2]int{}, false + } + row, column := xlsxCellPosition(strings.ToUpper(match[2]) + match[4]) + if match[1] == "" { + column += cell.column - origin.startColumn + } + if match[3] == "" { + row += cell.row - origin.startRow + } + return [2]int{row, column}, row > 0 && row <= 1048576 && column > 0 && column <= 16384 +} diff --git a/minipdf-go/xlsx_conditional_test.go b/minipdf-go/xlsx_conditional_test.go new file mode 100644 index 00000000..1a70e52c --- /dev/null +++ b/minipdf-go/xlsx_conditional_test.go @@ -0,0 +1,221 @@ +package minipdf + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestXLSXConditionalResultFillsAreDataDriven(t *testing.T) { + for _, result := range []string{"ACCEPT", "reject", "hold", "pending"} { + t.Run(result, func(t *testing.T) { + worksheet := `` + + `Title` + + fmt.Sprintf(`%s`, result) + + `` + + `ISNUMBER(SEARCH("accept",$H$16))=TRUE` + + `ISNUMBER(SEARCH("reject",$H$16))=TRUE` + + `ISNUMBER(SEARCH("hold",$H$16))=TRUE` + + `` + + `` + + `` + + `` + + `` + grid, err := readXLSXGrid([]byte(worksheet), nil) + if err != nil { + t.Fatal(err) + } + red, _ := parseXLSXColor("FF0000") + green, _ := parseXLSXColor("92D050") + yellow, _ := parseXLSXColor("FFFF00") + white := PDFColorWhite + styles, err := grid.applyConditionalFills([]xlsxStyle{{fill: &red}}, []*PDFColor{&white, &green, &red, &yellow}, nil) + if err != nil { + t.Fatal(err) + } + wantTitle, wantResult := white, green + switch result { + case "reject": + wantResult = red + case "hold": + wantResult = yellow + case "pending": + wantTitle, wantResult = red, red + } + for _, key := range [][2]int{{2, 5}, {2, 6}, {16, 8}} { + want := wantTitle + if key[0] == 16 { + want = wantResult + } + style := styleAt(styles, grid.cells[key].style) + if style.fill == nil || *style.fill != want { + t.Errorf("cell %v fill = %v, want %v", key, style.fill, want) + } + } + }) + } +} + +func TestXLSXConditionalFillsPriorityStopAndRelativeReferences(t *testing.T) { + for _, test := range []struct { + name, rules string + want PDFColor + }{ + {"priority", ``, PDFColorWhite}, + {"stop without fill", ``, PDFColorBlack}, + {"relative column", `ISNUMBER(SEARCH("match",B1))`, PDFColorWhite}, + {"mixed absolute row", `ISNUMBER(SEARCH("match",B$1))`, PDFColorWhite}, + } { + t.Run(test.name, func(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + `matchmatch`+ + `match`+ + ``+test.rules+``), nil) + if err != nil { + t.Fatal(err) + } + black, white := PDFColorBlack, PDFColorWhite + styles, err := grid.applyConditionalFills([]xlsxStyle{{fill: &black}}, []*PDFColor{&white, &black}, nil) + if err != nil { + t.Fatal(err) + } + for _, column := range []int{1, 2} { + if fill := styleAt(styles, grid.cells[[2]int{1, column}].style).fill; fill == nil || *fill != test.want { + t.Errorf("column %d fill = %v, want %v", column, fill, test.want) + } + } + if fill := styleAt(styles, grid.cells[[2]int{1, 3}].style).fill; *fill != black { + t.Fatal("conditional fill applied outside sqref") + } + }) + } +} + +func TestXLSXConditionalNumericBoundsAndErrors(t *testing.T) { + for _, operator := range []string{"between", "notBetween"} { + grid, err := readXLSXGrid([]byte(`515`+ + ``+ + `010`), nil) + if err != nil { + t.Fatal(err) + } + black, white := PDFColorBlack, PDFColorWhite + styles, err := grid.applyConditionalFills([]xlsxStyle{{fill: &black}}, []*PDFColor{&white}, nil) + if err != nil { + t.Fatal(err) + } + for _, column := range []int{1, 2} { + want := black + if (operator == "between" && column == 1) || (operator == "notBetween" && column == 2) { + want = white + } + if *styleAt(styles, grid.cells[[2]int{1, column}].style).fill != want { + t.Errorf("%s column %d did not match numeric bounds", operator, column) + } + } + } + for _, rule := range []string{`dxfId="0" priority="0"`, `dxfId="10" priority="1"`} { + grid, err := readXLSXGrid([]byte(``), nil) + if err != nil { + t.Fatal(err) + } + if _, err := grid.applyConditionalFills(nil, nil, nil); err == nil { + t.Fatalf("invalid rule %s accepted", rule) + } + } + for _, reference := range []string{"A0:B1", "R1C1:R2C2", "A1:XFE1", "A1:A1048577", ""} { + _, err := readXLSXGrid([]byte(``), nil) + if err == nil || !strings.Contains(err.Error(), "range") { + t.Fatalf("invalid range %q error = %v", reference, err) + } + } +} + +func TestXLSXConditionalSearchWildcards(t *testing.T) { + for _, test := range []struct { + pattern, value string + want bool + }{ + {"ac*pt", "Status: ACCEPT", true}, {"ac?pt", "accept", false}, + {"ac??pt", "accept", true}, {"~*", "literal *", true}, + {"~*", "accept", false}, {"~~", "~", true}, {`"accept"`, `"ACCEPT"`, true}, + } { + if got := matchesXLSXSearch(test.pattern, test.value); got != test.want { + t.Errorf("SEARCH(%q, %q) = %v, want %v", test.pattern, test.value, got, test.want) + } + } +} + +func TestConvertXLSXPrintedConditionalFills(t *testing.T) { + entries := map[string]string{ + "xl/workbook.xml": `` + + `Report!$A$1:$B$1`, + "xl/styles.xml": `` + + `` + + `` + + ``, + "xl/worksheets/sheet1.xml": `Accept` + + `` + + `ISNUMBER(SEARCH("accept",$A$1))=TRUE`, + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, "Accept") + if bytes.Contains(pdf, []byte("1 0 0 rg")) { + t.Fatal("printed grid retained red base fill instead of conditional theme-white fill") + } + if bytes.Count(pdf, []byte("1 1 1 rg")) != 2 { + t.Fatal("printed grid did not paint conditional fill on both stored and implicit blank cells") + } + if bytes.Count(pdf, []byte("/Type /Page ")) != 1 { + t.Fatal("conditional styles changed the printed page count") + } + limited, err := ConvertBytesToPDFWithOptions(officePackageBytes(t, entries), ConversionOptions{MaxColumns: 1}) + if err != nil { + t.Fatal(err) + } + if bytes.Count(limited, []byte("1 1 1 rg")) != 1 { + t.Fatal("conditional fills ignored the printed column limit") + } +} + +func TestXLSXImplicitConditionalFillsStayWithinPrintedScope(t *testing.T) { + for _, value := range []string{"Accept", "Pending"} { + grid, err := readXLSXGrid([]byte(``+ + value+``+ + `ISNUMBER(SEARCH("accept",$A$1))`+ + ``), nil) + if err != nil { + t.Fatal(err) + } + white := PDFColorWhite + _, err = grid.applyConditionalFills(nil, []*PDFColor{&white}, []xlsxMerge{{startRow: 1, endRow: 2, startColumn: 1, endColumn: 2}}) + if err != nil { + t.Fatal(err) + } + wantCells := 1 + if value == "Accept" { + wantCells = 4 + } + if len(grid.cells) != wantCells || grid.maxRow != 1 || grid.maxColumn != 1 { + t.Errorf("%s: cells = %d, dimensions = %d/%d; conditional fills expanded print scope or retained unmatched blanks", + value, len(grid.cells), grid.maxRow, grid.maxColumn) + } + } +} + +func TestXLSXConditionalMixedAbsoluteReferences(t *testing.T) { + origin := xlsxMerge{startRow: 3, startColumn: 3} + cell := xlsxCell{row: 5, column: 4} + for _, test := range []struct { + reference string + want [2]int + }{ + {"A1", [2]int{3, 2}}, {"$A1", [2]int{3, 1}}, + {"A$1", [2]int{1, 2}}, {"$A$1", [2]int{1, 1}}, + } { + if got, ok := conditionalXLSXReference(test.reference, cell, origin); !ok || got != test.want { + t.Errorf("%s = %v/%v, want %v", test.reference, got, ok, test.want) + } + } +} diff --git a/minipdf-go/xlsx_font.go b/minipdf-go/xlsx_font.go new file mode 100644 index 00000000..6e3b8bcb --- /dev/null +++ b/minipdf-go/xlsx_font.go @@ -0,0 +1,96 @@ +package minipdf + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/image/font" + "golang.org/x/image/font/sfnt" + "golang.org/x/image/math/fixed" +) + +func readXLSXFont(name string, bold, italic bool) (*embeddedFont, error) { + variant := name + if bold { + variant += " Bold" + } + if italic { + variant += " Italic" + } + normalize := func(value string) string { + return strings.ToLower(strings.NewReplacer(" ", "", "-", "").Replace(value)) + } + newFont := func(data []byte) (*embeddedFont, error) { + parsed, err := sfnt.Parse(data) + if err != nil { + return nil, fmt.Errorf("parse XLSX font %q: %w", variant, err) + } + if parsed.UnitsPerEm() == 0 { + return nil, fmt.Errorf("invalid XLSX font %q units-per-em", variant) + } + advances := make([]float64, parsed.NumGlyphs()) + metrics, err := parsed.Metrics(nil, fixed.Int26_6(parsed.UnitsPerEm()), font.HintingNone) + if err != nil { + return nil, fmt.Errorf("read XLSX font %q metrics: %w", variant, err) + } + for index := range advances { + advance, err := parsed.GlyphAdvance(nil, sfnt.GlyphIndex(index), fixed.Int26_6(parsed.UnitsPerEm()), font.HintingNone) + if err != nil { + return nil, fmt.Errorf("read XLSX font %q glyph metrics: %w", variant, err) + } + advances[index] = float64(advance) / float64(parsed.UnitsPerEm()) + } + return &embeddedFont{name: sanitizePDFFontName(variant), data: data, font: parsed, advances: advances, + ascent: float64(metrics.Ascent) / float64(parsed.UnitsPerEm()), descent: float64(metrics.Descent) / float64(parsed.UnitsPerEm()), + runeToGlyph: make(map[rune]sfnt.GlyphIndex), glyphToRune: make(map[sfnt.GlyphIndex]rune)}, nil + } + for _, registered := range RegisteredFonts() { + if normalize(registered.Name) == normalize(variant) { + return newFont(registered.Data) + } + } + if runtime.GOOS != "windows" { + return nil, nil + } + index := 0 + if bold { + index++ + } + if italic { + index += 2 + } + var files []string + switch strings.ToLower(name) { + case "calibri": + files = []string{"calibri.ttf", "calibrib.ttf", "calibrii.ttf", "calibriz.ttf"} + case "arial": + files = []string{"arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"} + default: + return nil, nil + } + windows := os.Getenv("WINDIR") + if windows == "" { + return nil, nil + } + path := filepath.Join(windows, "Fonts", files[index]) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read XLSX font %s: %w", path, err) + } + return newFont(data) +} + +func measureXLSXFont(text string, size float64, embedded *embeddedFont) float64 { + embedded.collect(text) + width := 0.0 + for _, character := range normalizedFontRunes(text) { + width += embedded.advances[embedded.runeToGlyph[character]] * size + } + return width +} diff --git a/minipdf-go/xlsx_footer.go b/minipdf-go/xlsx_footer.go new file mode 100644 index 00000000..58cec140 --- /dev/null +++ b/minipdf-go/xlsx_footer.go @@ -0,0 +1,190 @@ +package minipdf + +import ( + "fmt" + "strconv" + "strings" +) + +type xlsxHeaderFooter struct { + Odd string `xml:"oddFooter"` + Even string `xml:"evenFooter"` + First string `xml:"firstFooter"` + DifferentFirst bool `xml:"differentFirst,attr"` + DifferentOddEven bool `xml:"differentOddEven,attr"` + ScaleWithDoc *bool `xml:"scaleWithDoc,attr"` + runs [3][]xlsxFooterRun +} + +type xlsxFooterRun struct { + text string + field byte + portion, line int + font xlsxFont +} + +func parseXLSXFooter(value string, defaultFont xlsxFont, sheetName string) ([]xlsxFooterRun, error) { + current := defaultFont + if current.size <= 0 { + current.size = 11 + } + portion := 1 + lines := [3]int{} + var text strings.Builder + var runs []xlsxFooterRun + flush := func() { + if text.Len() > 0 { + runs = append(runs, xlsxFooterRun{text: text.String(), portion: portion, line: lines[portion], font: current}) + text.Reset() + } + } + for index := 0; index < len(value); index++ { + switch value[index] { + case '\n': + flush() + lines[portion]++ + case '&': + index++ + if index >= len(value) { + return nil, fmt.Errorf("incomplete XLSX footer control code") + } + switch token := value[index]; token { + case '&': + text.WriteByte('&') + case 'L', 'C', 'R': + flush() + portion = map[byte]int{'L': 0, 'C': 1, 'R': 2}[token] + case 'P', 'N': + flush() + runs = append(runs, xlsxFooterRun{field: token, portion: portion, line: lines[portion], font: current}) + case 'A': + text.WriteString(sheetName) + case 'B', 'I': + flush() + if token == 'B' { + current.bold = !current.bold + } else { + current.italic = !current.italic + } + var err error + current.embedded, err = readXLSXFont(current.name, current.bold, current.italic) + if err != nil { + return nil, err + } + case '"': + flush() + end := strings.IndexByte(value[index+1:], '"') + if end < 0 { + return nil, fmt.Errorf("unclosed XLSX footer font") + } + descriptor := strings.SplitN(value[index+1:index+1+end], ",", 2) + current.name = descriptor[0] + current.bold, current.italic = false, false + if len(descriptor) > 1 { + current.bold = strings.Contains(strings.ToLower(descriptor[1]), "bold") + current.italic = strings.Contains(strings.ToLower(descriptor[1]), "italic") + } + var err error + current.embedded, err = readXLSXFont(current.name, current.bold, current.italic) + if err != nil { + return nil, err + } + index += end + 1 + default: + if token >= '0' && token <= '9' { + flush() + end := index + 1 + for end < len(value) && ((value[end] >= '0' && value[end] <= '9') || value[end] == '.') { + end++ + } + size, err := strconv.ParseFloat(value[index:end], 64) + if err != nil || size <= 0 || size > 409 { + return nil, fmt.Errorf("invalid XLSX footer font size %q", value[index:end]) + } + current.size = size + index = end - 1 + } else { + return nil, fmt.Errorf("unsupported XLSX footer control code &%c", token) + } + } + default: + text.WriteByte(value[index]) + } + } + flush() + return runs, nil +} + +func (grid *xlsxGrid) prepareFooters(styles []xlsxStyle, sheetName string) error { + defaultFont := styleAt(styles, 0).font + for index, value := range []string{grid.headerFooter.Odd, grid.headerFooter.Even, grid.headerFooter.First} { + runs, err := parseXLSXFooter(value, defaultFont, sheetName) + if err != nil { + return err + } + grid.headerFooter.runs[index] = runs + } + return nil +} + +func xlsxFooterDecoration(grid xlsxGrid, scale float64, localPage int) func(int, int) []pdfOperation { + if grid.headerFooter.Odd == "" && grid.headerFooter.Even == "" && grid.headerFooter.First == "" { + return nil + } + if grid.headerFooter.ScaleWithDoc != nil && !*grid.headerFooter.ScaleWithDoc { + scale = 1 + } + return func(pageNumber, total int) []pdfOperation { + runs := grid.headerFooter.runs[0] + if grid.headerFooter.DifferentFirst && localPage == 1 { + runs = grid.headerFooter.runs[2] + } else if grid.headerFooter.DifferentOddEven && pageNumber%2 == 0 { + runs = grid.headerFooter.runs[1] + } + type lineKey struct{ portion, line int } + widths := make(map[lineKey]float64) + counts := [3]int{} + var operations []textOperation + keys := make([]lineKey, 0, len(runs)) + lineHeight := 0.0 + for _, run := range runs { + value := run.text + if run.field == 'P' { + value = strconv.Itoa(pageNumber) + } else if run.field == 'N' { + value = strconv.Itoa(total) + } + size := run.font.size * scale + lineHeight = max(lineHeight, size*1.4) + counts[run.portion] = max(counts[run.portion], run.line+1) + embedded := run.font.embedded + if embedded != nil && !embedded.canEncode(value) { + embedded = nil + } + width := measureHelveticaText(value, size, 100, run.font.bold) + if embedded != nil { + width = measureXLSXFont(value, size, embedded) + } + key := lineKey{run.portion, run.line} + operations = append(operations, textOperation{text: value, x: widths[key], fontSize: size, horizontalScale: 100, color: run.font.color, + bold: run.font.bold, italic: run.font.italic, embedded: embedded}) + keys = append(keys, key) + widths[key] += width + } + result := make([]pdfOperation, 0, len(operations)) + for index, operation := range operations { + key := keys[index] + switch key.portion { + case 0: + operation.x += grid.margins.Left + case 1: + operation.x += (grid.pageSize.Width - widths[key]) / 2 + case 2: + operation.x += grid.pageSize.Width - grid.margins.Right - widths[key] + } + operation.y = grid.footerMargin + float64(counts[key.portion]-key.line-1)*lineHeight + operation.fontSize*0.3 + result = append(result, operation) + } + return result + } +} diff --git a/minipdf-go/xlsx_layout_test.go b/minipdf-go/xlsx_layout_test.go new file mode 100644 index 00000000..89eb56b8 --- /dev/null +++ b/minipdf-go/xlsx_layout_test.go @@ -0,0 +1,269 @@ +package minipdf + +import ( + "bytes" + "image" + "image/color" + "image/png" + "math" + "strings" + "testing" + + "golang.org/x/image/font/gofont/gobold" + "golang.org/x/image/font/gofont/goregular" +) + +func TestPrintedXLSXNumberFormats(t *testing.T) { + for _, test := range []struct { + value string + id int + code string + date1904 bool + want string + }{ + {"46046", 164, `dd\-mmm\-yyyy`, false, "24-Jan-2026"}, + {"1", 164, "yyyy-mm-dd", false, "1900-01-01"}, + {"0", 164, "dd-mmm-yyyy", false, "00-Jan-1900"}, + {"1", 164, "dddd", false, "Sunday"}, + {"59", 164, "yyyy-mm-dd", false, "1900-02-28"}, + {"60", 164, "yyyy-mm-dd", false, "1900-02-29"}, + {"61", 164, "yyyy-mm-dd", false, "1900-03-01"}, + {"0", 164, "yyyy-mm-dd", true, "1904-01-01"}, + {"46087", 14, "", false, "3/6/2026"}, + {"61859", 3, "", false, "61,859"}, + {"-1234.567", 4, "", false, "-1,234.57"}, + {"1.25", 165, "0.0", false, "1.3"}, + {"1.5", 165, "0.##", false, "1.5"}, + {"0.125", 9, "", false, "13%"}, + {"132.22999999999999", 0, "", false, "132.23"}, + {"123.4", 164, "[Red]0.00", false, "123.4"}, + {"-123.4", 164, "[Red]0.00", false, "-123.4"}, + {"123.4", 164, `0.0"days"`, false, "123.4"}, + } { + value, err := formatPrintedXLSXValue(test.value, test.id, test.code, test.date1904) + if err != nil || value != test.want { + t.Errorf("%q format %d/%q date1904=%v = %q, %v; want %q", test.value, test.id, test.code, test.date1904, value, err, test.want) + } + } + for _, value := range []string{"invalid", "NaN", "Inf"} { + if _, err := formatPrintedXLSXValue(value, 0, "", false); err == nil { + t.Errorf("invalid numeric value %q accepted", value) + } + if _, err := formatPrintedXLSXValue("2957004", 164, "yyyy-mm-dd", true); err == nil { + t.Fatal("1904 date beyond year 9999 accepted") + } + } +} + +func TestXLSXPrintCenteringIncludesRepeatedColumns(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + `TitleBody`+ + ``), nil) + if err != nil { + t.Fatal(err) + } + document := NewPDFDocument() + settings := xlsxPrintSettings{areas: []xlsxMerge{{startRow: 1, endRow: 1, startColumn: 2, endColumn: 2}}, columns: xlsxRange{start: 1, end: 1}} + if err := renderXLSXPrintGrid(document, grid, nil, settings, ConversionOptions{}); err != nil { + t.Fatal(err) + } + width := grid.columnWidth(1) + grid.columnWidth(2) + wantX := (grid.pageSize.Width-width)/2 + 1.5 + for _, operation := range document.pages[0].operations { + text, ok := operation.(textOperation) + if ok && text.text == "Title" { + if math.Abs(text.x-wantX) > 1e-6 { + t.Errorf("repeated column x = %g, want %g", text.x, wantX) + } + if text.y < grid.pageSize.Height/2-20 || text.y > grid.pageSize.Height/2+20 { + t.Errorf("vertical centering y = %g", text.y) + } + return + } + } + t.Fatal("missing repeated column") +} + +func TestXLSXFooterUsesFinalDocumentPageCountWithoutMutation(t *testing.T) { + grid, err := readXLSXGrid([]byte(`Body`+ + `&L&6 Left && literal Second&CPage &P of &N&R&A`), nil) + if err != nil { + t.Fatal(err) + } + if err := grid.prepareFooters(nil, "Report"); err != nil { + t.Fatal(err) + } + doc := NewPDFDocument() + settings := xlsxPrintSettings{areas: []xlsxMerge{{startRow: 1, endRow: 1, startColumn: 1, endColumn: 1}}} + for i := 0; i < 2; i++ { + if err := renderXLSXPrintGrid(doc, grid, nil, settings, ConversionOptions{}); err != nil { + t.Fatal(err) + } + } + count := len(doc.pages[0].operations) + pdf := doc.Bytes() + for _, text := range []string{"Left & literal", "Second", "Report"} { + if !bytes.Contains(pdf, []byte(text)) { + t.Errorf("missing footer %q", text) + } + } + for page := 1; page <= 2; page++ { + operations := doc.pages[page-1].decorations(page, 2) + var text strings.Builder + for _, operation := range operations { + text.WriteString(operation.(textOperation).text) + } + if !strings.Contains(text.String(), "Page "+string(rune('0'+page))+" of 2") { + t.Errorf("footer = %q", text.String()) + } + } + if len(doc.pages[0].operations) != count || !bytes.Equal(pdf, doc.Bytes()) { + t.Fatal("PDF serialization mutated or duplicated footers") + } +} + +func TestXLSXFooterFirstEvenAndUnscaledVariants(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + `&6Odd&6Even&6First`+ + ``), nil) + if err != nil { + t.Fatal(err) + } + if err := grid.prepareFooters(nil, "Sheet"); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + local, global int + want string + }{{1, 3, "First"}, {2, 4, "Even"}, {3, 5, "Odd"}} { + operations := xlsxFooterDecoration(grid, 0.5, test.local)(test.global, 5) + if len(operations) != 1 { + t.Fatal(operations) + } + text := operations[0].(textOperation) + if text.text != test.want || text.fontSize != 6 { + t.Errorf("footer = %#v", text) + } + } + if _, err := parseXLSXFooter(`&"Unclosed`, xlsxFont{size: 11}, ""); err == nil { + t.Fatal("unclosed footer font accepted") + } +} + +func TestXLSXPictureCropRelationshipAndTitleRepeat(t *testing.T) { + source := image.NewRGBA(image.Rect(0, 0, 8, 6)) + for y := 0; y < 6; y++ { + for x := 0; x < 8; x++ { + source.Set(x, y, color.RGBA{R: uint8(x * 20), G: uint8(y * 30), A: 255}) + } + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, source); err != nil { + t.Fatal(err) + } + entries := map[string]string{ + "xl/worksheets/sheet1.xml": ``, + "xl/worksheets/_rels/sheet1.xml.rels": ``, + "xl/drawings/drawing1.xml": `00` + + ``, + "xl/drawings/_rels/drawing1.xml.rels": ``, + "xl/media/logo.png": string(encoded.Bytes()), + } + grid, err := readXLSXGrid([]byte(``+ + ``), nil) + if err != nil { + t.Fatal(err) + } + pictures, err := readXLSXPictures(xlsxStylePackage(t, entries), "xl/worksheets/sheet1.xml", grid) + if err != nil { + t.Fatal(err) + } + if len(pictures) != 1 || pictures[0].image.pixelWidth != 4 || pictures[0].image.pixelHeight != 6 { + t.Fatalf("pictures = %#v", pictures) + } + grid.pictures = pictures + doc := NewPDFDocument() + settings := xlsxPrintSettings{areas: []xlsxMerge{{startRow: 1, endRow: 3, startColumn: 1, endColumn: 1}}, rows: xlsxRange{start: 1, end: 1}} + if err := renderXLSXPrintGrid(doc, grid, nil, settings, ConversionOptions{}); err != nil { + t.Fatal(err) + } + if len(doc.pages) != 2 { + t.Fatal("missing page break") + } + for _, page := range doc.pages { + images := 0 + for _, operation := range page.operations { + if _, ok := operation.(imageOperation); ok { + images++ + } + } + if images != 1 { + t.Errorf("repeated logo count = %d, want 1", images) + } + } + if !bytes.Contains(doc.Bytes(), []byte("/ASCIIHexDecode /FlateDecode")) { + t.Fatal("missing inline raster image") + } + delete(entries, "xl/drawings/_rels/drawing1.xml.rels") + if _, err := readXLSXPictures(xlsxStylePackage(t, entries), "xl/worksheets/sheet1.xml", grid); err == nil { + t.Fatal("missing picture relationship part accepted") + } +} + +func TestPrintedXLSXNamedFontVariantsAndWidths(t *testing.T) { + ClearRegisteredFonts() + defer ClearRegisteredFonts() + RegisterFont("Unit Font", goregular.TTF) + RegisterFont("Unit Font Bold", gobold.TTF) + regular, err := readXLSXFont("Unit Font", false, false) + if err != nil { + t.Fatal(err) + } + bold, err := readXLSXFont("Unit Font", true, false) + if err != nil { + t.Fatal(err) + } + if regular == nil || bold == nil || bytes.Equal(regular.data, bold.data) { + t.Fatal("font variants were not resolved independently") + } + width := measureXLSXFont("00", 10, regular) + if math.Abs(width-2*measureXLSXFont("0", 10, regular)) > 1e-9 { + t.Fatal("font advance widths are inconsistent") + } + doc := NewPDFDocument() + page := doc.AddPage(200, 200) + renderXLSXCellText(page, xlsxCell{value: "Regular"}, 0, 100, 100, 20, xlsxStyle{font: xlsxFont{size: 10, embedded: regular}}) + renderXLSXCellText(page, xlsxCell{value: "Bold"}, 0, 70, 100, 20, xlsxStyle{font: xlsxFont{size: 10, bold: true, embedded: bold}}) + pdf := doc.Bytes() + if bytes.Count(pdf, []byte("/Subtype /Type0")) != 2 { + t.Fatal("PDF did not embed both named font variants") + } + if !bytes.Contains(pdf, []byte("/FU1")) || !bytes.Contains(pdf, []byte("/FU2")) { + t.Fatal("missing font resources") + } + if !bytes.Equal(pdf, doc.Bytes()) { + t.Fatal("font serialization is not repeatable") + } + shrunk := doc.AddPage(200, 200) + renderXLSXCellText(shrunk, xlsxCell{value: "0000000000"}, 0, 0, 20, 20, xlsxStyle{shrink: true, font: xlsxFont{size: 10, embedded: regular}}) + text := shrunk.operations[0].(textOperation) + if text.fontSize >= 10 || measureXLSXFont(text.text, text.fontSize, regular) > 16+1e-8 { + t.Fatal("shrink-to-fit does not use the selected font's advance widths") + } + if math.Abs(text.y-text.fontSize*regular.descent) > 1e-8 { + t.Fatal("bottom alignment ignored actual font descent") + } +} + +func TestXLSXDoubleBordersKeepTwoSeparateStrokes(t *testing.T) { + page := NewPDFDocument().AddPage(100, 100) + renderXLSXCell(page, xlsxCell{}, 10, 10, 20, 20, xlsxStyle{border: xlsxBorder{bottom: 1.5, bottomDouble: true}}, false) + if len(page.operations) != 2 { + t.Fatal("double border was rendered as a single thick line") + } + first := page.operations[0].(lineOperation) + second := page.operations[1].(lineOperation) + if first.y1 == second.y1 || first.width != 0.5 || second.width != 0.5 { + t.Fatalf("double border = %#v / %#v", first, second) + } +} diff --git a/minipdf-go/xlsx_number.go b/minipdf-go/xlsx_number.go new file mode 100644 index 00000000..1b1b78b5 --- /dev/null +++ b/minipdf-go/xlsx_number.go @@ -0,0 +1,192 @@ +package minipdf + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +var xlsxSimpleNumberFormat = regexp.MustCompile(`^[#0]+(?:,[#0]+)*(?:\.[0#]+)?%?$`) + +func formatPrintedXLSXValue(value string, id int, code string, date1904 bool) (string, error) { + number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || math.IsNaN(number) || math.IsInf(number, 0) { + return "", fmt.Errorf("invalid numeric XLSX value %q", value) + } + if code == "" { + code = map[int]string{1: "0", 2: "0.00", 3: "#,##0", 4: "#,##0.00", 9: "0%", 10: "0.00%", 14: "m/d/yyyy", 15: "d-mmm-yy", 16: "d-mmm", 17: "mmm-yy"}[id] + } + if code == "" && id == 0 { + return strconv.FormatFloat(number, 'g', 15, 64), nil + } + datePattern, quoted, bracket := false, false, false + for index := 0; index < len(code); index++ { + if code[index] == '\\' { + index++ + if index >= len(code) { + return "", fmt.Errorf("incomplete XLSX format escape %q", code) + } + } else if code[index] == '"' { + quoted = !quoted + } else if !quoted && code[index] == '[' { + bracket = true + } else if !quoted && code[index] == ']' { + bracket = false + } else if !quoted && !bracket && strings.ContainsAny(strings.ToLower(code[index:index+1]), "dy") { + datePattern = true + } + } + if quoted { + return "", fmt.Errorf("unclosed XLSX format literal %q", code) + } + if datePattern { + maximum := 2958465.0 + if date1904 { + maximum -= 1462 + } + if number < 0 || number > maximum { + return "", fmt.Errorf("XLSX date serial %g is outside the supported range", number) + } + day := int(math.Floor(number)) + date := time.Date(1899, 12, 31, 0, 0, 0, 0, time.UTC) + if date1904 { + date = time.Date(1904, 1, 1, 0, 0, 0, 0, time.UTC) + } else if day >= 60 { + day-- + } + date = date.AddDate(0, 0, day) + dayOfMonth := date.Day() + weekday := date.Weekday() + if !date1904 && int(number) < 61 { + weekday = time.Weekday((int(number) + 6) % 7) + } + if !date1904 && int(number) == 0 { + date = time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC) + dayOfMonth = 0 + } + if !date1904 && int(number) == 60 { + dayOfMonth = 29 + } + var output strings.Builder + for index := 0; index < len(code); { + character := code[index] + if character == '\\' { + index++ + if index >= len(code) { + return "", fmt.Errorf("incomplete XLSX format escape %q", code) + } + output.WriteByte(code[index]) + index++ + continue + } + if character == '"' { + end := strings.IndexByte(code[index+1:], '"') + if end < 0 { + return "", fmt.Errorf("unclosed XLSX format literal %q", code) + } + output.WriteString(code[index+1 : index+1+end]) + index += end + 2 + continue + } + lower := strings.ToLower(string(character)) + end := index + 1 + for end < len(code) && strings.EqualFold(code[end:end+1], lower) { + end++ + } + count := end - index + switch lower { + case "d": + switch count { + case 1: + output.WriteString(strconv.Itoa(dayOfMonth)) + case 2: + fmt.Fprintf(&output, "%02d", dayOfMonth) + case 3: + output.WriteString(weekday.String()[:3]) + case 4: + output.WriteString(weekday.String()) + default: + return value, nil + } + case "m": + switch count { + case 1: + output.WriteString(strconv.Itoa(int(date.Month()))) + case 2: + fmt.Fprintf(&output, "%02d", date.Month()) + case 3: + output.WriteString(date.Format("Jan")) + case 4: + output.WriteString(date.Format("January")) + default: + return value, nil + } + case "y": + if count == 2 { + fmt.Fprintf(&output, "%02d", date.Year()%100) + } else if count == 4 { + fmt.Fprintf(&output, "%04d", date.Year()) + } else { + return value, nil + } + default: + if (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || strings.ContainsRune("[];_", rune(character)) { + return value, nil + } + output.WriteString(code[index:end]) + } + index = end + } + return output.String(), nil + } + if !xlsxSimpleNumberFormat.MatchString(code) { + return value, nil + } + percent := strings.HasSuffix(code, "%") + if percent { + number *= 100 + code = strings.TrimSuffix(code, "%") + } + precision, minimum := 0, 0 + if index := strings.IndexByte(code, '.'); index >= 0 { + fraction := code[index+1:] + precision = len(fraction) + minimum = strings.Count(fraction, "0") + } + if precision > 30 { + return value, nil + } + factor := math.Pow10(precision) + if math.Abs(number) <= math.MaxFloat64/factor { + number = math.Round(number*factor) / factor + } + formatted := strconv.FormatFloat(number, 'f', precision, 64) + parts := strings.SplitN(formatted, ".", 2) + digits := parts[0] + sign := "" + if strings.HasPrefix(digits, "-") { + sign, digits = "-", digits[1:] + } + if strings.Contains(code, ",") { + for index := len(digits) - 3; index > 0; index -= 3 { + digits = digits[:index] + "," + digits[index:] + } + } + result := sign + digits + if len(parts) > 1 { + fraction := parts[1] + for len(fraction) > minimum && strings.HasSuffix(fraction, "0") { + fraction = strings.TrimSuffix(fraction, "0") + } + if fraction != "" { + result += "." + fraction + } + } + if percent { + result += "%" + } + return result, nil +} diff --git a/minipdf-go/xlsx_picture.go b/minipdf-go/xlsx_picture.go new file mode 100644 index 00000000..6f27a503 --- /dev/null +++ b/minipdf-go/xlsx_picture.go @@ -0,0 +1,122 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "math" + "strings" +) + +type xlsxPicture struct { + anchorRow, anchorColumn int + rowOffset, columnOffset, width, height float64 + image imageOperation +} + +func readXLSXPictures(files officePackage, worksheetName string, grid xlsxGrid) ([]xlsxPicture, error) { + name, drawing, err := readXLSXDrawing(files, worksheetName) + if err != nil { + return nil, err + } + anchors := append(drawing.OneCell, drawing.TwoCell...) + var pictures []xlsxPicture + var targets map[string]string + for _, anchor := range anchors { + if anchor.Picture == nil || (anchor.Client.Print != nil && !*anchor.Client.Print) { + continue + } + if anchor.From.Row < 0 || anchor.From.Row >= 1048576 || anchor.From.Column < 0 || anchor.From.Column >= 16384 || + anchor.From.RowOffset < 0 || anchor.From.ColumnOffset < 0 || anchor.To.Row > 1048576 || anchor.To.Column > 16384 { + return nil, fmt.Errorf("invalid XLSX picture anchor in %s", name) + } + if targets == nil { + part := xlsxRelationshipPartName(name) + data, err := files.read(part) + if err != nil { + return nil, err + } + var relationships xlsxRelationshipsDocument + if err := xml.Unmarshal(data, &relationships); err != nil { + return nil, fmt.Errorf("parse %s: %w", part, err) + } + targets = make(map[string]string) + for _, relationship := range relationships.Items { + if strings.HasSuffix(relationship.Type, "/image") { + if relationship.TargetMode == "External" { + return nil, fmt.Errorf("unsupported external XLSX picture %s", relationship.ID) + } + targets[relationship.ID] = xlsxResolveRelationshipTarget(name, relationship.Target) + } + } + } + target := targets[anchor.Picture.Fill.Blip.ID] + if target == "" { + return nil, fmt.Errorf("missing XLSX picture relationship %q", anchor.Picture.Fill.Blip.ID) + } + data, err := files.read(target) + if err != nil { + return nil, err + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode XLSX picture %s: %w", target, err) + } + if config.Width <= 0 || config.Height <= 0 || int64(config.Width) > 25000000/int64(config.Height) { + return nil, fmt.Errorf("XLSX picture %s exceeds 25 million pixels", target) + } + source, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode XLSX picture %s: %w", target, err) + } + crop := anchor.Picture.Fill.Crop + if min(crop.Left, crop.Right, crop.Top, crop.Bottom) < 0 || max(crop.Left, crop.Right, crop.Top, crop.Bottom) > 100000 || + crop.Left+crop.Right >= 100000 || crop.Top+crop.Bottom >= 100000 { + return nil, fmt.Errorf("unsupported XLSX picture crop in %s", name) + } + bounds := source.Bounds() + left := bounds.Min.X + int(math.Round(float64(bounds.Dx()*crop.Left)/100000)) + top := bounds.Min.Y + int(math.Round(float64(bounds.Dy()*crop.Top)/100000)) + cropWidth := int(math.Round(float64(bounds.Dx()*(100000-crop.Left-crop.Right)) / 100000)) + cropHeight := int(math.Round(float64(bounds.Dy()*(100000-crop.Top-crop.Bottom)) / 100000)) + rectangle := image.Rect(left, top, min(bounds.Max.X, left+cropWidth), min(bounds.Max.Y, top+cropHeight)) + raster, err := newPDFImage(source, rectangle) + if err != nil { + return nil, fmt.Errorf("read XLSX picture %s: %w", target, err) + } + x := float64(anchor.From.ColumnOffset) / xlsxEmuPerPoint + y := float64(anchor.From.RowOffset) / xlsxEmuPerPoint + width := float64(anchor.Extension.Width) / xlsxEmuPerPoint + height := float64(anchor.Extension.Height) / xlsxEmuPerPoint + if width <= 0 { + width = grid.columnOffset(anchor.To.Column) - grid.columnOffset(anchor.From.Column) + float64(anchor.To.ColumnOffset)/xlsxEmuPerPoint - x + } + if height <= 0 { + height = grid.rowOffset(anchor.To.Row) - grid.rowOffset(anchor.From.Row) + float64(anchor.To.RowOffset)/xlsxEmuPerPoint - y + } + if width <= 0 || height <= 0 { + return nil, fmt.Errorf("invalid XLSX picture size in %s", name) + } + pictures = append(pictures, xlsxPicture{anchorRow: anchor.From.Row + 1, anchorColumn: anchor.From.Column + 1, + columnOffset: x, rowOffset: y, width: width, height: height, image: raster}) + } + return pictures, nil +} + +func renderXLSXPictures(page *PDFPage, grid xlsxGrid, rows, columns xlsxRange) { + for _, picture := range grid.pictures { + if picture.anchorRow < rows.start || picture.anchorRow > rows.end || picture.anchorColumn < columns.start || picture.anchorColumn > columns.end || + grid.hiddenRows[picture.anchorRow] || grid.hiddenColumns[picture.anchorColumn] { + continue + } + operation := picture.image + operation.x = grid.margins.Left + xlsxRangeSize(xlsxRange{start: columns.start, end: picture.anchorColumn - 1}, grid.columnWidth) + picture.columnOffset*grid.layoutScale() + operation.y = grid.pageSize.Height - grid.margins.Top - xlsxRangeSize(xlsxRange{start: rows.start, end: picture.anchorRow - 1}, grid.rowHeight) - (picture.rowOffset+picture.height)*grid.layoutScale() + operation.width, operation.height = picture.width*grid.layoutScale(), picture.height*grid.layoutScale() + page.operations = append(page.operations, operation) + } +} diff --git a/minipdf-go/xlsx_print.go b/minipdf-go/xlsx_print.go new file mode 100644 index 00000000..b99cfece --- /dev/null +++ b/minipdf-go/xlsx_print.go @@ -0,0 +1,240 @@ +package minipdf + +import ( + "fmt" + "math" + "strings" +) + +type xlsxPrintSettings struct { + areas []xlsxMerge + rows, columns xlsxRange + date1904 bool + sheetName string +} + +func parseXLSXPrintRanges(value string) ([]xlsxMerge, error) { + var references []string + start := 0 + quoted := false + for index, character := range value { + switch character { + case '\'': + quoted = !quoted + case ',': + if !quoted { + references = append(references, value[start:index]) + start = index + 1 + } + } + } + if quoted { + return nil, fmt.Errorf("unclosed sheet name in print range %q", value) + } + references = append(references, value[start:]) + var ranges []xlsxMerge + for _, reference := range references { + reference = strings.TrimSpace(reference) + if index := strings.LastIndex(reference, "!"); index >= 0 { + reference = reference[index+1:] + } + reference = strings.ReplaceAll(reference, "$", "") + parts := strings.Split(reference, ":") + if len(parts) == 1 { + parts = append(parts, parts[0]) + } + if len(parts) != 2 { + return nil, fmt.Errorf("unsupported print range %q", reference) + } + startRow, startColumn := xlsxCellPosition(parts[0]) + endRow, endColumn := xlsxCellPosition(parts[1]) + switch { + case startRow == 0 && endRow == 0 && startColumn > 0 && endColumn > 0 && + strings.Trim(parts[0], "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") == "" && + strings.Trim(parts[1], "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") == "": + startRow, endRow = 1, 1048576 + case startColumn == 0 && endColumn == 0 && startRow > 0 && endRow > 0: + startColumn, endColumn = 1, 16384 + } + if startRow <= 0 || startColumn <= 0 || endRow < startRow || endColumn < startColumn || + endRow > 1048576 || endColumn > 16384 { + return nil, fmt.Errorf("invalid print range %q", reference) + } + ranges = append(ranges, xlsxMerge{startRow: startRow, endRow: endRow, startColumn: startColumn, endColumn: endColumn}) + } + return ranges, nil +} + +func xlsxRangeSize(span xlsxRange, size func(int) float64) float64 { + total := 0.0 + for index := span.start; index > 0 && index <= span.end; index++ { + total += size(index) + } + return total +} + +func xlsxPrintBands(span xlsxRange, available float64, size func(int) float64, titles xlsxRange, breaks map[int]bool) ([]xlsxRange, error) { + var bands []xlsxRange + titleSize := xlsxRangeSize(titles, size) + for start := span.start; start <= span.end; { + capacity := available + if titles.start > 0 && start > titles.end { + capacity -= titleSize + } + if capacity <= 0 { + return nil, fmt.Errorf("XLSX print titles exceed printable page size") + } + end := start + used := 0.0 + for end <= span.end { + if end > start && (breaks[end-1] || used+size(end) > capacity+1e-6) { + break + } + used += size(end) + end++ + } + bands = append(bands, xlsxRange{start: start, end: end - 1}) + start = end + } + return bands, nil +} + +func limitXLSXPrintArea(grid xlsxGrid, area xlsxMerge, options ConversionOptions) xlsxMerge { + if area.endRow == 1048576 { + area.endRow = grid.maxRow + } + if area.endColumn == 16384 { + area.endColumn = grid.maxColumn + } + if options.MaxRows > 0 { + area.endRow = min(area.endRow, options.MaxRows) + } + if options.MaxColumns > 0 { + area.endColumn = min(area.endColumn, options.MaxColumns) + } + return area +} + +func renderXLSXPrintGrid(document *PDFDocument, grid xlsxGrid, styles []xlsxStyle, settings xlsxPrintSettings, options ConversionOptions) error { + grid.printLayout = true + firstPage := len(document.pages) + availableWidth := grid.pageSize.Width - grid.margins.Left - grid.margins.Right + availableHeight := grid.pageSize.Height - grid.margins.Top - grid.margins.Bottom + if availableWidth <= 0 || availableHeight <= 0 { + return fmt.Errorf("XLSX margins leave no printable page area") + } + for _, area := range settings.areas { + area = limitXLSXPrintArea(grid, area, options) + if area.endRow < area.startRow || area.endColumn < area.startColumn { + continue + } + printGrid := grid + rowBreaks, columnBreaks := grid.rowBreaks, grid.columnBreaks + if grid.fitToPage { + printGrid.printScale = 1 + scale := math.Inf(1) + if grid.fitWidth > 0 { + width := xlsxRangeSize(xlsxRange{start: area.startColumn, end: area.endColumn}, printGrid.columnWidth) + if settings.columns.start > 0 { + repeats := grid.fitWidth - 1 + if area.startColumn > settings.columns.end { + repeats++ + } + width += xlsxRangeSize(settings.columns, printGrid.columnWidth) * float64(repeats) + } + if width > 0 { + scale = min(scale, availableWidth*float64(grid.fitWidth)/width) + } + } + if grid.fitHeight > 0 { + height := xlsxRangeSize(xlsxRange{start: area.startRow, end: area.endRow}, printGrid.rowHeight) + if settings.rows.start > 0 { + repeats := grid.fitHeight - 1 + if area.startRow > settings.rows.end { + repeats++ + } + height += xlsxRangeSize(settings.rows, printGrid.rowHeight) * float64(repeats) + } + if height > 0 { + scale = min(scale, availableHeight*float64(grid.fitHeight)/height) + } + } + if !math.IsInf(scale, 1) { + if scale <= 0 { + return fmt.Errorf("XLSX print titles leave no fit-to-page space") + } + printGrid.printScale = min(1, scale) + } + rowBreaks, columnBreaks = nil, nil + } + columnBands, err := xlsxPrintBands(xlsxRange{start: area.startColumn, end: area.endColumn}, availableWidth, printGrid.columnWidth, settings.columns, columnBreaks) + if err != nil { + return err + } + rowBands, err := xlsxPrintBands(xlsxRange{start: area.startRow, end: area.endRow}, availableHeight, printGrid.rowHeight, settings.rows, rowBreaks) + if err != nil { + return err + } + renderBand := func(rowBand, columnBand xlsxRange) { + page := document.AddPage(grid.pageSize.Width, grid.pageSize.Height) + bandGrid := printGrid + if grid.horizontalCentered { + width := xlsxRangeSize(columnBand, printGrid.columnWidth) + if settings.columns.start > 0 && columnBand.start > settings.columns.end { + width += xlsxRangeSize(settings.columns, printGrid.columnWidth) + } + bandGrid.margins.Left += max(0, (availableWidth-width)/2) + } + if grid.verticalCentered { + height := xlsxRangeSize(rowBand, printGrid.rowHeight) + if settings.rows.start > 0 && rowBand.start > settings.rows.end { + height += xlsxRangeSize(settings.rows, printGrid.rowHeight) + } + bandGrid.margins.Top += max(0, (availableHeight-height)/2) + } + renderXLSXPrintBand(page, bandGrid, styles, rowBand, columnBand, settings) + localPage := len(document.pages) - firstPage + page.decorations = xlsxFooterDecoration(grid, printGrid.layoutScale(), localPage) + } + if grid.overThenDown { + for _, rowBand := range rowBands { + for _, columnBand := range columnBands { + renderBand(rowBand, columnBand) + } + } + } else { + for _, columnBand := range columnBands { + for _, rowBand := range rowBands { + renderBand(rowBand, columnBand) + } + } + } + } + return nil +} + +func renderXLSXPrintBand(page *PDFPage, grid xlsxGrid, styles []xlsxStyle, rows, columns xlsxRange, settings xlsxPrintSettings) { + body := grid + repeatRows := settings.rows.start > 0 && rows.start > settings.rows.end + repeatColumns := settings.columns.start > 0 && columns.start > settings.columns.end + if repeatRows { + body.margins.Top += xlsxRangeSize(settings.rows, grid.rowHeight) + } + if repeatColumns { + body.margins.Left += xlsxRangeSize(settings.columns, grid.columnWidth) + } + if repeatRows { + header := grid + header.margins.Left = body.margins.Left + renderXLSXBand(page, header, styles, settings.rows, columns) + } + if repeatColumns { + header := grid + header.margins.Top = body.margins.Top + renderXLSXBand(page, header, styles, rows, settings.columns) + } + if repeatRows && repeatColumns { + renderXLSXBand(page, grid, styles, settings.rows, settings.columns) + } + renderXLSXBand(page, body, styles, rows, columns) +} diff --git a/minipdf-go/xlsx_print_test.go b/minipdf-go/xlsx_print_test.go new file mode 100644 index 00000000..18494891 --- /dev/null +++ b/minipdf-go/xlsx_print_test.go @@ -0,0 +1,254 @@ +package minipdf + +import ( + "bytes" + "strings" + "testing" +) + +func TestParseXLSXPrintRanges(t *testing.T) { + tests := []struct { + reference string + want []xlsxMerge + }{ + {"'O''Brien, report'!$B$2:$D$80,'O''Brien, report'!$F$5", []xlsxMerge{ + {startRow: 2, endRow: 80, startColumn: 2, endColumn: 4}, + {startRow: 5, endRow: 5, startColumn: 6, endColumn: 6}, + }}, + {"Photos!$A:$Y", []xlsxMerge{{startRow: 1, endRow: 1048576, startColumn: 1, endColumn: 25}}}, + {"Photos!$2:$11", []xlsxMerge{{startRow: 2, endRow: 11, startColumn: 1, endColumn: 16384}}}, + } + for _, test := range tests { + t.Run(test.reference, func(t *testing.T) { + got, err := parseXLSXPrintRanges(test.reference) + if err != nil { + t.Fatal(err) + } + if len(got) != len(test.want) { + t.Fatalf("ranges = %#v, want %#v", got, test.want) + } + for index := range got { + if got[index] != test.want[index] { + t.Fatalf("ranges = %#v, want %#v", got, test.want) + } + } + }) + } + for _, reference := range []string{"", "'Unclosed!A1:B2", "Sheet!#REF!", "A0:B2", "B2:A1", "A1:XFE2", "A1:A1048577", "R1C1:R2C2"} { + if _, err := parseXLSXPrintRanges(reference); err == nil { + t.Fatalf("invalid print range %q was accepted", reference) + } + } +} + +func TestConvertXLSXPrintAreaUsesRelationshipsAndPreservesSheetOrder(t *testing.T) { + entries := map[string]string{ + "xl/workbook.xml": `` + + `` + + `Printed!$B$2:$D$3`, + "xl/_rels/workbook.xml.rels": `` + + `` + + `` + + ``, + "xl/worksheets/sheet1.xml": `Before`, + "xl/worksheets/sheet2.xml": `` + + `Outside top` + + `PrintedHelper column` + + `Trailing data` + + ``, + "xl/worksheets/sheet3.xml": `After`, + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, "Before", "Printed", "After") + for _, value := range []string{"Outside top", "Helper column", "Trailing data"} { + if bytes.Contains(pdf, []byte(value)) { + t.Fatalf("PDF contains out-of-print-area text %q", value) + } + } + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 3 { + t.Fatalf("pages = %d, want 3", pages) + } + if before, printed, after := bytes.Index(pdf, []byte("(Before)")), bytes.Index(pdf, []byte("(Printed)")), bytes.Index(pdf, []byte("(After)")); before >= printed || printed >= after { + t.Fatal("mixed grid and text worksheets changed their order") + } +} + +func TestConvertXLSXPrintAreaRespectsHiddenCellsScaleAndRepeatedTitles(t *testing.T) { + entries := map[string]string{ + "xl/workbook.xml": `` + + `Report!$B$2:$D$80` + + `Report!$2:$2` + + ``, + "xl/worksheets/sheet1.xml": `` + + `` + + `` + + `HeaderHidden column` + + `` + + `First body` + + `Second body` + + `` + + ``, + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, "Header", "First body", "Second body", "/F1 5.5 Tf") + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != 2 { + t.Fatalf("pages = %d, want 2 scaled pages separated by the manual break", pages) + } + if count := bytes.Count(pdf, []byte("(Header)")); count != 2 { + t.Fatalf("repeated header count = %d, want 2", count) + } + for _, text := range []string{"Hidden row", "Hidden column"} { + if bytes.Contains(pdf, []byte(text)) { + t.Fatalf("PDF contains %q", text) + } + } +} + +func TestXLSXPrintBandsReserveTitleSpace(t *testing.T) { + bands, err := xlsxPrintBands(xlsxRange{start: 2, end: 11}, 100, func(int) float64 { return 20 }, xlsxRange{start: 2, end: 3}, map[int]bool{6: true}) + if err != nil { + t.Fatal(err) + } + want := []xlsxRange{{start: 2, end: 6}, {start: 7, end: 9}, {start: 10, end: 11}} + if len(bands) != len(want) { + t.Fatalf("bands = %#v, want %#v", bands, want) + } + for index := range bands { + if bands[index] != want[index] { + t.Fatalf("bands = %#v, want %#v", bands, want) + } + } + if _, err := xlsxPrintBands(xlsxRange{start: 4, end: 10}, 30, func(int) float64 { return 20 }, xlsxRange{start: 1, end: 3}, nil); err == nil { + t.Fatal("oversized repeated titles were accepted") + } +} + +func TestXLSXPrintFitModeOverridesPercentAndManualBreaks(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + ``+ + `Header`+ + ``+ + ``), nil) + if err != nil { + t.Fatal(err) + } + document := NewPDFDocument() + err = renderXLSXPrintGrid(document, grid, nil, xlsxPrintSettings{areas: []xlsxMerge{{startRow: 1, endRow: 100, startColumn: 1, endColumn: 4}}}, ConversionOptions{}) + if err != nil { + t.Fatal(err) + } + if len(document.pages) != 1 { + t.Fatalf("fit-to-page pages = %d, want 1", len(document.pages)) + } +} + +func TestConvertXLSXPrintAreaAllowsShapesWithoutDrawingRelationships(t *testing.T) { + entries := map[string]string{ + "xl/workbook.xml": `` + + `Report!A1:B2`, + "xl/worksheets/sheet1.xml": `Report`, + "xl/worksheets/_rels/sheet1.xml.rels": ``, + "xl/drawings/drawing1.xml": ``, + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, "Report") +} + +func TestConvertXLSXRejectsInvalidPrintSettings(t *testing.T) { + for _, value := range []string{"Report!#REF!", "Report!B2:A1"} { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": `` + + value + ``, + "xl/worksheets/sheet1.xml": ``, + }) + _, err := ConvertBytesToPDF(input) + assertPackageErrorContains(t, err, "Print_Area") + } + for _, setting := range []string{`scale="-1"`, `fitToWidth="-1"`} { + _, err := readXLSXGrid([]byte(``), nil) + if err == nil || !strings.Contains(err.Error(), "invalid XLSX") { + t.Fatalf("setting %s: error = %v", setting, err) + } + } +} + +func TestXLSXPrintAreaRepeatsRowsAndColumns(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + `Corner`+ + `Row title`+ + `Column title`+ + ``), nil) + if err != nil { + t.Fatal(err) + } + grid.pageSize = PageSize{Width: 400, Height: 400} + grid.margins = Margins{Left: 10, Right: 10, Top: 10, Bottom: 10} + for index := 1; index <= 6; index++ { + grid.columnWidths[index] = 100 + grid.rowHeights[index] = 100 + } + document := NewPDFDocument() + err = renderXLSXPrintGrid(document, grid, nil, xlsxPrintSettings{ + areas: []xlsxMerge{{startRow: 1, endRow: 6, startColumn: 1, endColumn: 6}}, + rows: xlsxRange{start: 1, end: 1}, + columns: xlsxRange{start: 1, end: 1}, + }, ConversionOptions{}) + if err != nil { + t.Fatal(err) + } + pdf := document.Bytes() + if len(document.pages) != 9 { + t.Fatalf("pages = %d, want 9 with repeated rows and columns", len(document.pages)) + } + for text, want := range map[string]int{"Corner": 9, "Row title": 3, "Column title": 3} { + if count := bytes.Count(pdf, []byte("("+text+")")); count != want { + t.Fatalf("%s count = %d, want %d", text, count, want) + } + } +} + +func TestXLSXPrintAreaSupportsMultipleRangesAndOptionLimits(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": `` + + `Report!A1:B2,Report!D10:E11`, + "xl/worksheets/sheet1.xml": `` + + `First range` + + `Second range`, + }) + for _, maxRows := range []int{0, 5} { + pdf, err := ConvertBytesToPDFWithOptions(input, ConversionOptions{MaxRows: maxRows}) + assertPDFContains(t, pdf, err, "First range") + wantPages := 2 + if maxRows > 0 { + wantPages = 1 + if bytes.Contains(pdf, []byte("Second range")) { + t.Fatal("MaxRows did not constrain the print area") + } + } else { + assertPDFContains(t, pdf, nil, "Second range") + } + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != wantPages { + t.Fatalf("MaxRows=%d: pages = %d, want %d", maxRows, pages, wantPages) + } + } +} + +func TestXLSXPrintAreaPaintsTextAfterAdjacentFills(t *testing.T) { + grid, err := readXLSXGrid([]byte(``+ + `Long report title`+ + ``), nil) + if err != nil { + t.Fatal(err) + } + fill := PDFColor{Red: 1} + styles := []xlsxStyle{{font: xlsxFont{size: 11}, fill: &fill}} + document := NewPDFDocument() + err = renderXLSXPrintGrid(document, grid, styles, xlsxPrintSettings{areas: []xlsxMerge{{startRow: 1, endRow: 1, startColumn: 1, endColumn: 2}}}, ConversionOptions{}) + if err != nil { + t.Fatal(err) + } + operations := document.pages[0].operations + if _, ok := operations[len(operations)-1].(textOperation); !ok { + t.Fatal("adjacent cell fill can paint over the overflowing report title") + } +} diff --git a/minipdf-go/xlsx_render.go b/minipdf-go/xlsx_render.go index c963ad11..548e8a2a 100644 --- a/minipdf-go/xlsx_render.go +++ b/minipdf-go/xlsx_render.go @@ -18,23 +18,29 @@ const ( ) type xlsxStyle struct { - font xlsxFont - fill *PDFColor - border xlsxBorder - numberFmt int - align string - vertical string + font xlsxFont + fill *PDFColor + border xlsxBorder + numberFmt int + numberCode string + align string + vertical string + shrink bool + padding float64 } type xlsxFont struct { - name string - size float64 - color PDFColor - bold bool + name string + size float64 + color PDFColor + bold bool + italic bool + embedded *embeddedFont } type xlsxBorder struct { - left, right, top, bottom float64 + left, right, top, bottom float64 + leftDouble, rightDouble, topDouble, bottomDouble bool } type xlsxCell struct { @@ -43,6 +49,7 @@ type xlsxCell struct { formula string style int numeric bool + formatted string } type xlsxMerge struct { @@ -51,43 +58,56 @@ type xlsxMerge struct { } type xlsxGrid struct { - cells map[[2]int]xlsxCell - columnWidths map[int]float64 - rowHeights map[int]float64 - merges []xlsxMerge - charts []xlsxChart - maxRow, maxColumn int - dimensionRow int - dimensionColumn int - defaultRowHeight float64 - pageSize PageSize - margins Margins + cells map[[2]int]xlsxCell + columnWidths map[int]float64 + rowHeights map[int]float64 + merges []xlsxMerge + charts []xlsxChart + pictures []xlsxPicture + horizontalCentered, verticalCentered bool + footerMargin float64 + headerFooter xlsxHeaderFooter + conditionalFills []xlsxConditionalFormat + maxRow, maxColumn int + dimensionRow int + dimensionColumn int + defaultRowHeight float64 + pageSize PageSize + margins Margins + hiddenRows map[int]bool + hiddenColumns map[int]bool + rowBreaks map[int]bool + columnBreaks map[int]bool + printLayout bool + printScale float64 + fitToPage bool + fitWidth int + fitHeight int + overThenDown bool } type xlsxStylesDocument struct { Fonts struct { Items []struct { - Bold *struct{} `xml:"b"` - Name struct { + Bold *struct{} `xml:"b"` + Italic *struct{} `xml:"i"` + Name struct { Value string `xml:"val,attr"` } `xml:"name"` Size struct { Value float64 `xml:"val,attr"` } `xml:"sz"` - Color struct { - RGB string `xml:"rgb,attr"` - } `xml:"color"` + Color xlsxColorXML `xml:"color"` } `xml:"font"` } `xml:"fonts"` - Fills struct { + NumberFormats struct { Items []struct { - Pattern struct { - Type string `xml:"patternType,attr"` - Color struct { - RGB string `xml:"rgb,attr"` - } `xml:"fgColor"` - } `xml:"patternFill"` - } `xml:"fill"` + ID int `xml:"numFmtId,attr"` + Code string `xml:"formatCode,attr"` + } `xml:"numFmt"` + } `xml:"numFmts"` + Fills struct { + Items []xlsxFillXML `xml:"fill"` } `xml:"fills"` Borders struct { Items []struct { @@ -114,9 +134,15 @@ type xlsxStylesDocument struct { Alignment struct { Horizontal string `xml:"horizontal,attr"` Vertical string `xml:"vertical,attr"` + Shrink bool `xml:"shrinkToFit,attr"` } `xml:"alignment"` } `xml:"xf"` } `xml:"cellXfs"` + DifferentialFormats struct { + Items []struct { + Fill *xlsxFillXML `xml:"fill"` + } `xml:"dxf"` + } `xml:"dxfs"` } func needsCalculatedXLSXGrid(data []byte) bool { @@ -142,23 +168,45 @@ func needsCalculatedXLSXGrid(data []byte) bool { return formulaCount > 0 } -func readXLSXStyles(files officePackage) ([]xlsxStyle, error) { +func readXLSXStyles(files officePackage) ([]xlsxStyle, []*PDFColor, error) { if _, ok := files["xl/styles.xml"]; !ok { - return nil, nil + return nil, nil, nil } data, err := files.read("xl/styles.xml") if err != nil { - return nil, err + return nil, nil, err } var document xlsxStylesDocument if err := xml.Unmarshal(data, &document); err != nil { - return nil, fmt.Errorf("parse xl/styles.xml: %w", err) + return nil, nil, fmt.Errorf("parse xl/styles.xml: %w", err) + } + theme, err := readXLSXTheme(files) + if err != nil { + return nil, nil, err } styles := make([]xlsxStyle, len(document.CellFormats.Items)) + fonts := make(map[int]*embeddedFont) + type fontKey struct { + name string + bold, italic bool + } + variants := make(map[fontKey]*embeddedFont) + for index, font := range document.Fonts.Items { + key := fontKey{strings.ToLower(font.Name.Value), font.Bold != nil, font.Italic != nil} + embedded, exists := variants[key] + if !exists { + embedded, err = readXLSXFont(font.Name.Value, key.bold, key.italic) + if err != nil { + return nil, nil, err + } + variants[key] = embedded + } + fonts[index] = embedded + } for index, format := range document.CellFormats.Items { style := xlsxStyle{ font: xlsxFont{size: 11, color: PDFColorBlack}, numberFmt: format.NumberFmt, - align: format.Alignment.Horizontal, vertical: format.Alignment.Vertical, + align: format.Alignment.Horizontal, vertical: format.Alignment.Vertical, shrink: format.Alignment.Shrink, } if format.FontID >= 0 && format.FontID < len(document.Fonts.Items) { font := document.Fonts.Items[format.FontID] @@ -167,16 +215,24 @@ func readXLSXStyles(files officePackage) ([]xlsxStyle, error) { style.font.size = font.Size.Value } style.font.bold = font.Bold != nil - if color, ok := parseXLSXColor(font.Color.RGB); ok { - style.font.color = color + style.font.italic = font.Italic != nil + style.font.embedded = fonts[format.FontID] + color, err := resolveXLSXColor(font.Color, theme) + if err != nil { + return nil, nil, fmt.Errorf("parse xl/styles.xml font %d: %w", format.FontID, err) + } + if color != nil { + style.font.color = *color } } if format.FillID >= 0 && format.FillID < len(document.Fills.Items) { fill := document.Fills.Items[format.FillID].Pattern if fill.Type == "solid" { - if color, ok := parseXLSXColor(fill.Color.RGB); ok { - style.fill = &color + color, err := resolveXLSXColor(fill.Foreground, theme) + if err != nil { + return nil, nil, fmt.Errorf("parse xl/styles.xml fill %d: %w", format.FillID, err) } + style.fill = color } } if format.BorderID >= 0 && format.BorderID < len(document.Borders.Items) { @@ -184,11 +240,37 @@ func readXLSXStyles(files officePackage) ([]xlsxStyle, error) { style.border = xlsxBorder{ left: borderWidth(border.Left.Style), right: borderWidth(border.Right.Style), top: borderWidth(border.Top.Style), bottom: borderWidth(border.Bottom.Style), + leftDouble: border.Left.Style == "double", rightDouble: border.Right.Style == "double", + topDouble: border.Top.Style == "double", bottomDouble: border.Bottom.Style == "double", } } styles[index] = style + for _, number := range document.NumberFormats.Items { + if number.ID == format.NumberFmt { + styles[index].numberCode = number.Code + break + } + } } - return styles, nil + differentialFills := make([]*PDFColor, len(document.DifferentialFormats.Items)) + for index, format := range document.DifferentialFormats.Items { + if format.Fill == nil { + continue + } + pattern := format.Fill.Pattern + if pattern.Type != "" && pattern.Type != "solid" { + continue + } + color := pattern.Background + if color.RGB == "" && color.Theme == nil { + color = pattern.Foreground + } + differentialFills[index], err = resolveXLSXColor(color, theme) + if err != nil { + return nil, nil, fmt.Errorf("parse xl/styles.xml differential fill %d: %w", index, err) + } + } + return styles, differentialFills, nil } func parseXLSXColor(value string) (PDFColor, bool) { @@ -217,32 +299,88 @@ func borderWidth(style string) float64 { } } -func renderXLSXWorksheets(files officePackage, worksheetNames []string, sharedStrings []string, styles []xlsxStyle, options ConversionOptions) ([]byte, error) { +func renderXLSXWorksheets(files officePackage, worksheetNames []string, sharedStrings []string, styles []xlsxStyle, differentialFills []*PDFColor, options ConversionOptions, printSettings map[string]xlsxPrintSettings) ([]byte, error) { document := NewPDFDocument() for _, name := range worksheetNames { - data, err := files.read(name) - if err != nil { + if err := renderXLSXWorksheet(document, files, name, sharedStrings, styles, differentialFills, options, printSettings[strings.ToLower(name)]); err != nil { return nil, err } - grid, err := readXLSXGrid(data, sharedStrings) + } + return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}), nil +} + +func renderXLSXWorksheet(document *PDFDocument, files officePackage, name string, sharedStrings []string, styles []xlsxStyle, differentialFills []*PDFColor, options ConversionOptions, settings xlsxPrintSettings) error { + data, err := files.read(name) + if err != nil { + return err + } + grid, err := readXLSXGrid(data, sharedStrings) + if err != nil { + return fmt.Errorf("parse %s: %w", name, err) + } + grid.charts, err = readXLSXCharts(files, name, &grid) + if err != nil { + return err + } + grid.applyOptions(options) + if len(settings.areas) > 0 { + grid.pictures, err = readXLSXPictures(files, name, grid) if err != nil { - return nil, fmt.Errorf("parse %s: %w", name, err) + return err + } + for key, cell := range grid.cells { + if cell.value != "" && cell.numeric { + style := styleAt(styles, cell.style) + cell.formatted, err = formatPrintedXLSXValue(cell.value, style.numberFmt, style.numberCode, settings.date1904) + if err != nil { + return fmt.Errorf("format %s cell %d/%d: %w", name, cell.row, cell.column, err) + } + grid.cells[key] = cell + } + } + if err := grid.prepareFooters(styles, settings.sheetName); err != nil { + return fmt.Errorf("parse %s footer: %w", name, err) + } + var scopes []xlsxMerge + for _, area := range settings.areas { + area = limitXLSXPrintArea(grid, area, options) + if area.endRow < area.startRow || area.endColumn < area.startColumn { + continue + } + scopes = append(scopes, area) + if settings.rows.start > 0 { + scopes = append(scopes, xlsxMerge{startRow: settings.rows.start, endRow: settings.rows.end, startColumn: area.startColumn, endColumn: area.endColumn}) + } + if settings.columns.start > 0 { + scopes = append(scopes, xlsxMerge{startRow: area.startRow, endRow: area.endRow, startColumn: settings.columns.start, endColumn: settings.columns.end}) + } + if settings.rows.start > 0 && settings.columns.start > 0 { + scopes = append(scopes, xlsxMerge{startRow: settings.rows.start, endRow: settings.rows.end, startColumn: settings.columns.start, endColumn: settings.columns.end}) + } } - grid.charts, err = readXLSXCharts(files, name, &grid) + styles, err = grid.applyConditionalFills(styles, differentialFills, scopes) if err != nil { - return nil, err + return fmt.Errorf("render %s conditional fills: %w", name, err) + } + if err := renderXLSXPrintGrid(document, grid, styles, settings, options); err != nil { + return fmt.Errorf("render %s print area: %w", name, err) } - grid.applyOptions(options) + } else { renderXLSXGrid(document, grid, styles) } - return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}), nil + return nil } func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { grid := xlsxGrid{ cells: make(map[[2]int]xlsxCell), columnWidths: make(map[int]float64), rowHeights: make(map[int]float64), defaultRowHeight: 15, pageSize: PageSizeA4, margins: Margins{Left: 54, Right: 54, Top: 72, Bottom: 72}, + hiddenRows: make(map[int]bool), hiddenColumns: make(map[int]bool), + rowBreaks: make(map[int]bool), columnBreaks: make(map[int]bool), + printScale: 1, fitWidth: 1, fitHeight: 1, + footerMargin: 36, } + breakKind := "" decoder := xml.NewDecoder(bytes.NewReader(data)) for { token, err := decoder.Token() @@ -254,9 +392,41 @@ func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { } start, ok := token.(xml.StartElement) if !ok { + if end, ok := token.(xml.EndElement); ok && (end.Name.Local == "rowBreaks" || end.Name.Local == "colBreaks") { + breakKind = "" + } continue } switch start.Name.Local { + case "printOptions": + grid.horizontalCentered = attrValue(start, "horizontalCentered") == "1" || attrValue(start, "horizontalCentered") == "true" + grid.verticalCentered = attrValue(start, "verticalCentered") == "1" || attrValue(start, "verticalCentered") == "true" + case "headerFooter": + if err := decoder.DecodeElement(&grid.headerFooter, &start); err != nil { + return xlsxGrid{}, err + } + case "conditionalFormatting": + var format xlsxConditionalFormat + if err := decoder.DecodeElement(&format, &start); err != nil { + return xlsxGrid{}, err + } + for _, reference := range strings.Fields(format.References) { + reference = strings.ReplaceAll(reference, "$", "") + if !strings.Contains(reference, ":") { + reference += ":" + reference + } + area, ok := parseXLSXMerge(reference) + endpoints := strings.Split(reference, ":") + if !ok || len(endpoints) != 2 || !xlsxConditionalReference.MatchString(endpoints[0]) || + !xlsxConditionalReference.MatchString(endpoints[1]) || area.endRow > 1048576 || area.endColumn > 16384 { + return xlsxGrid{}, fmt.Errorf("invalid XLSX conditional formatting range %q", reference) + } + format.areas = append(format.areas, area) + } + if len(format.areas) == 0 { + return xlsxGrid{}, fmt.Errorf("missing XLSX conditional formatting range") + } + grid.conditionalFills = append(grid.conditionalFills, format) case "dimension": reference := attrValue(start, "ref") parts := strings.Split(reference, ":") @@ -269,11 +439,10 @@ func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { minimum, _ := strconv.Atoi(attrValue(start, "min")) maximum, _ := strconv.Atoi(attrValue(start, "max")) width, _ := strconv.ParseFloat(attrValue(start, "width"), 64) - if grid.dimensionColumn > 0 { - maximum = min(maximum, grid.dimensionColumn) - } + maximum = min(maximum, 16384) for column := minimum; column <= maximum && column > 0; column++ { grid.columnWidths[column] = width * xlsxColumnWidthPoints + grid.hiddenColumns[column] = attrValue(start, "hidden") == "1" || attrValue(start, "hidden") == "true" } case "row": row, _ := strconv.Atoi(attrValue(start, "r")) @@ -281,6 +450,7 @@ func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { grid.rowHeights[row] = height } grid.maxRow = max(grid.maxRow, row) + grid.hiddenRows[row] = attrValue(start, "hidden") == "1" || attrValue(start, "hidden") == "true" case "c": reference := attrValue(start, "r") row, column := xlsxCellPosition(reference) @@ -298,17 +468,56 @@ func readXLSXGrid(data []byte, sharedStrings []string) (xlsxGrid, error) { grid.merges = append(grid.merges, merge) } case "pageMargins": + grid.footerMargin = inchesToPoints(attrValue(start, "footer"), 36) grid.margins = Margins{ Left: inchesToPoints(attrValue(start, "left"), 54), Right: inchesToPoints(attrValue(start, "right"), 54), Top: inchesToPoints(attrValue(start, "top"), 72), Bottom: inchesToPoints(attrValue(start, "bottom"), 72), } case "pageSetup": + grid.overThenDown = attrValue(start, "pageOrder") == "overThenDown" + if value := attrValue(start, "scale"); value != "" { + scale, err := strconv.Atoi(value) + if err != nil || (scale != 0 && scale < 10) || scale > 400 { + return xlsxGrid{}, fmt.Errorf("invalid XLSX print scale %q", value) + } + if scale > 0 { + grid.printScale = float64(scale) / 100 + } + } + for _, setting := range []struct { + name string + target *int + }{{"fitToWidth", &grid.fitWidth}, {"fitToHeight", &grid.fitHeight}} { + if value := attrValue(start, setting.name); value != "" { + count, err := strconv.Atoi(value) + if err != nil || count < 0 { + return xlsxGrid{}, fmt.Errorf("invalid XLSX %s %q", setting.name, value) + } + *setting.target = count + } + } if attrValue(start, "paperSize") == "1" { grid.pageSize = PageSizeLetter } if attrValue(start, "orientation") == "landscape" { grid.pageSize.Width, grid.pageSize.Height = grid.pageSize.Height, grid.pageSize.Width } + case "pageSetUpPr": + grid.fitToPage = attrValue(start, "fitToPage") == "1" || attrValue(start, "fitToPage") == "true" + case "rowBreaks", "colBreaks": + breakKind = start.Name.Local + case "brk": + if attrValue(start, "man") == "1" || attrValue(start, "man") == "true" { + index, err := strconv.Atoi(attrValue(start, "id")) + if err != nil || index < 0 { + return xlsxGrid{}, fmt.Errorf("invalid XLSX manual page break") + } + if breakKind == "rowBreaks" { + grid.rowBreaks[index] = true + } else if breakKind == "colBreaks" { + grid.columnBreaks[index] = true + } + } } } if grid.dimensionRow > 0 && grid.dimensionColumn > 0 { @@ -457,20 +666,51 @@ func xlsxBands(count int, available float64, size func(int) float64) []xlsxRange } func (grid xlsxGrid) columnWidth(column int) float64 { - if width := grid.columnWidths[column]; width > 0 { - return width + if grid.printLayout && grid.hiddenColumns[column] { + return 0 } - return 8.43 * xlsxColumnWidthPoints + scale := grid.layoutScale() + if width := grid.columnWidths[column]; width > 0 && (grid.printLayout || grid.dimensionColumn == 0 || column <= grid.dimensionColumn) { + return width * scale + } + return 8.43 * xlsxColumnWidthPoints * scale } func (grid xlsxGrid) rowHeight(row int) float64 { + if grid.printLayout && grid.hiddenRows[row] { + return 0 + } + scale := grid.layoutScale() if height := grid.rowHeights[row]; height > 0 { - return height + return height * scale } - return grid.defaultRowHeight * xlsxDefaultRowHeightScale + return grid.defaultRowHeight * xlsxDefaultRowHeightScale * scale +} + +func (grid xlsxGrid) layoutScale() float64 { + if grid.printLayout { + return grid.printScale + } + return 1 } func renderXLSXBand(page *PDFPage, grid xlsxGrid, styles []xlsxStyle, rows, columns xlsxRange) { + type cellText struct { + cell xlsxCell + x, y, width, height float64 + style xlsxStyle + } + var textCells []cellText + paintCell := func(cell xlsxCell, x, y, width, height float64, style xlsxStyle, drawText bool) { + if grid.printLayout { + renderXLSXCell(page, cell, x, y, width, height, style, false) + if drawText && width > 0 && height > 0 { + textCells = append(textCells, cellText{cell: cell, x: x, y: y, width: width, height: height, style: style}) + } + } else { + renderXLSXCell(page, cell, x, y, width, height, style, drawText) + } + } xPositions := map[int]float64{columns.start: grid.margins.Left} for column := columns.start; column <= columns.end; column++ { xPositions[column+1] = xPositions[column] + grid.columnWidth(column) @@ -494,10 +734,13 @@ func renderXLSXBand(page *PDFPage, grid xlsxGrid, styles []xlsxStyle, rows, colu endColumn := min(merge.endColumn, columns.end) startRow := max(merge.startRow, rows.start) endRow := min(merge.endRow, rows.end) - renderXLSXCell(page, cell, xPositions[startColumn], yPositions[endRow+1], xPositions[endColumn+1]-xPositions[startColumn], yPositions[startRow]-yPositions[endRow+1], styleAt(styles, cell.style), merge.startColumn >= columns.start && merge.startColumn <= columns.end) + paintCell(cell, xPositions[startColumn], yPositions[endRow+1], xPositions[endColumn+1]-xPositions[startColumn], yPositions[startRow]-yPositions[endRow+1], grid.scaledStyle(styles, cell.style), merge.startColumn >= columns.start && merge.startColumn <= columns.end) } for row := rows.start; row <= rows.end; row++ { for column := columns.start; column <= columns.end; column++ { + if grid.rowHeight(row) == 0 || grid.columnWidth(column) == 0 { + continue + } if merged[[2]int{row, column}] { continue } @@ -505,10 +748,34 @@ func renderXLSXBand(page *PDFPage, grid xlsxGrid, styles []xlsxStyle, rows, colu if !exists { continue } - renderXLSXCell(page, cell, xPositions[column], yPositions[row+1], grid.columnWidth(column), grid.rowHeight(row), styleAt(styles, cell.style), true) + paintCell(cell, xPositions[column], yPositions[row+1], grid.columnWidth(column), grid.rowHeight(row), grid.scaledStyle(styles, cell.style), true) } } + for _, text := range textCells { + renderXLSXCellText(page, text.cell, text.x, text.y, text.width, text.height, text.style) + } renderXLSXCharts(page, grid, rows, columns) + if grid.printLayout { + renderXLSXPictures(page, grid, rows, columns) + } +} + +func (grid xlsxGrid) scaledStyle(styles []xlsxStyle, index int) xlsxStyle { + style := styleAt(styles, index) + if !grid.printLayout { + style.font.embedded = nil + style.font.italic = false + style.shrink = false + } else { + style.padding = 1.5 * grid.layoutScale() + } + scale := grid.layoutScale() + style.font.size *= scale + style.border.left *= scale + style.border.right *= scale + style.border.top *= scale + style.border.bottom *= scale + return style } func styleAt(styles []xlsxStyle, index int) xlsxStyle { @@ -519,28 +786,60 @@ func styleAt(styles []xlsxStyle, index int) xlsxStyle { } func renderXLSXCell(page *PDFPage, cell xlsxCell, x, y, width, height float64, style xlsxStyle, drawText bool) { + if width <= 0 || height <= 0 { + return + } if style.fill != nil { page.AddRect(x, y, width, height, *style.fill) } if style.border.left > 0 { - page.AddLine(x, y, x, y+height, PDFColorBlack, style.border.left) + drawXLSXBorder(page, x, y, x, y+height, style.border.left, style.border.leftDouble) } if style.border.right > 0 { - page.AddLine(x+width, y, x+width, y+height, PDFColorBlack, style.border.right) + drawXLSXBorder(page, x+width, y, x+width, y+height, style.border.right, style.border.rightDouble) } if style.border.top > 0 { - page.AddLine(x, y+height, x+width, y+height, PDFColorBlack, style.border.top) + drawXLSXBorder(page, x, y+height, x+width, y+height, style.border.top, style.border.topDouble) } if style.border.bottom > 0 { - page.AddLine(x, y, x+width, y, PDFColorBlack, style.border.bottom) + drawXLSXBorder(page, x, y, x+width, y, style.border.bottom, style.border.bottomDouble) } if !drawText || cell.value == "" { return } + renderXLSXCellText(page, cell, x, y, width, height, style) +} + +func drawXLSXBorder(page *PDFPage, x1, y1, x2, y2, width float64, double bool) { + if !double { + page.AddLine(x1, y1, x2, y2, PDFColorBlack, width) + return + } + offset := width / 3 + if x1 == x2 { + page.AddLine(x1-offset, y1, x2-offset, y2, PDFColorBlack, offset) + page.AddLine(x1+offset, y1, x2+offset, y2, PDFColorBlack, offset) + } else { + page.AddLine(x1, y1-offset, x2, y2-offset, PDFColorBlack, offset) + page.AddLine(x1, y1+offset, x2, y2+offset, PDFColorBlack, offset) + } +} + +func renderXLSXCellText(page *PDFPage, cell xlsxCell, x, y, width, height float64, style xlsxStyle) { + if cell.value == "" { + return + } text := formatXLSXValue(cell.value, cell.numeric, style.numberFmt) + if cell.formatted != "" { + text = cell.formatted + } fontSize := style.font.size horizontalScale := 100.0 - if strings.EqualFold(style.font.name, "Calibri") { + embedded := style.font.embedded + if embedded != nil && !embedded.canEncode(text) { + embedded = nil + } + if embedded == nil && strings.EqualFold(style.font.name, "Calibri") { fontSize *= calibriToHelveticaFontScale horizontalScale = calibriRegularWidthScale if style.font.bold { @@ -548,24 +847,45 @@ func renderXLSXCell(page *PDFPage, cell xlsxCell, x, y, width, height float64, s } } textWidth := measureHelveticaText(text, fontSize, horizontalScale, style.font.bold) - textX := x + 2 + if embedded != nil { + textWidth = measureXLSXFont(text, fontSize, embedded) + } + padding := style.padding + if padding == 0 { + padding = 2 + } + if style.shrink && textWidth > width-2*padding && width > 2*padding { + ratio := (width - 2*padding) / textWidth + fontSize *= ratio + textWidth *= ratio + } + textX := x + padding switch style.align { case "center": textX = x + (width-textWidth)/2 case "right": - textX = x + width - textWidth - 2 + textX = x + width - textWidth - padding + case "left": + textX = x + padding default: if cell.numeric { - textX = x + width - textWidth - 2 + textX = x + width - textWidth - padding } } textY := y + fontSize*helveticaBaselineRatio + if embedded != nil { + textY = y + fontSize*embedded.descent + } if style.vertical == "center" { textY = y + (height-fontSize)/2 } else if style.vertical == "top" { textY = y + height - fontSize + if embedded != nil { + textY = y + height - fontSize*embedded.ascent + } } - page.addScaledText(text, textX, textY, fontSize, horizontalScale, style.font.color, style.font.bold) + page.operations = append(page.operations, textOperation{text: text, x: textX, y: textY, fontSize: fontSize, + horizontalScale: horizontalScale, color: style.font.color, bold: style.font.bold, italic: style.font.italic, embedded: embedded}) } func measureHelveticaText(text string, fontSize, horizontalScale float64, bold bool) float64 {