diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 0b78268e..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 | M | M | B | -| Print area and print titles | 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 | 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 8390a4a9..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" @@ -235,6 +236,261 @@ func TestConvertXLSXSkipsEmptyWorksheets(t *testing.T) { } } +func TestConvertXLSXSkipsHiddenWorksheets(t *testing.T) { + sheet := func(text string) string { + return `` + + `` + text + `` + } + entries := 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"), + "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) + } + }) + } +} + +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") + } +} + +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") + } + }) + } +} + +func TestConvertXLSXResolvesHiddenWorksheetParts(t *testing.T) { + const ( + visibleA = `` + hiddenA = `` + hiddenB = `` + visibleB = `` + first = `` + second = `` + ) + tests := []struct { + name string + sheets string + relationships []string + parts [2]string + present []string + absent []string + pages int + }{ + { + name: "missing relationships map sheets to non-contiguous parts by position", + sheets: hiddenA + visibleB, + parts: [2]string{"sheet2.xml", "sheet3.xml"}, + present: []string{"Second part"}, absent: []string{"First part"}, pages: 1, + }, + { + name: "missing relationships map sheets to custom part names by position", + sheets: hiddenA + visibleB, + parts: [2]string{"data.xml", "summary.xml"}, + present: []string{"Second part"}, absent: []string{"First part"}, pages: 1, + }, + { + name: "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, + relationships: []string{``, second}, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, + { + name: "missing relationships map sheets to parts by position", + sheets: visibleA + hiddenB, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, + { + name: "hidden sheet with a dangling relationship id is not rendered", + sheets: visibleA + ``, + relationships: []string{``}, + present: []string{"Second part"}, absent: []string{"First part"}, pages: 1, + }, + { + name: "visible sheet with a dangling relationship id is kept", + sheets: `` + hiddenB, + relationships: []string{second}, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, + { + name: "visible sheet whose target matches no part is kept", + sheets: visibleA + hiddenB, + relationships: []string{``, second}, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, + { + name: "relationship targets match part names case-insensitively", + sheets: visibleA + hiddenB, + relationships: []string{first, ``}, + present: []string{"First part"}, absent: []string{"Second part"}, pages: 1, + }, + { + name: "all sheets hidden produce one blank page", + sheets: hiddenA + hiddenB, + relationships: []string{first, second}, + absent: []string{"First part", "Second part"}, pages: 1, + }, + { + name: "all sheets hidden without relationships produce one blank page", + sheets: hiddenA + hiddenB, + absent: []string{"First part", "Second part"}, pages: 1, + }, + { + 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) { + parts := test.parts + if parts == [2]string{} { + parts = [2]string{"sheet1.xml", "sheet2.xml"} + } + entries := map[string]string{ + "xl/workbook.xml": `` + + test.sheets + ``, + "xl/worksheets/" + parts[0]: `First part`, + "xl/worksheets/" + parts[1]: `Second part`, + } + if test.relationships != nil { + entries["xl/_rels/workbook.xml.rels"] = `` + strings.Join(test.relationships, "") + `` + } + pdf, err := ConvertBytesToPDF(officePackageBytes(t, entries)) + assertPDFContains(t, pdf, err, test.present...) + for _, text := range test.absent { + if bytes.Contains(pdf, []byte(text)) { + t.Fatalf("PDF contains hidden worksheet text %q", text) + } + } + if pages := bytes.Count(pdf, []byte("/Type /Page ")); pages != test.pages { + t.Fatalf("page count = %d, want %d", pages, test.pages) + } + }) + } +} + func TestSplitWorksheetColumnGroups(t *testing.T) { lines := []string{ "A\tB\tC\tD\tE\tF\tG\tH\tI\tJ", diff --git a/minipdf-go/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 7800a98d..ec250980 100644 --- a/minipdf-go/xlsx.go +++ b/minipdf-go/xlsx.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/xml" "fmt" + "io" "strconv" "strings" ) @@ -21,7 +22,11 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { if len(worksheets) == 0 { return nil, errorsNewMissingWorksheets() } - styles, err := readXLSXStyles(files) + worksheets, printSettings, err := visibleXLSXWorksheets(files, worksheets) + if err != nil { + return nil, err + } + styles, differentialFills, err := readXLSXStyles(files) if err != nil { return nil, err } @@ -38,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 @@ -67,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 @@ -76,14 +94,172 @@ 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 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, map[string]xlsxPrintSettings, error) { + const workbookName = "xl/workbook.xml" + if _, ok := files[workbookName]; !ok { + return worksheets, nil, nil + } + data, err := files.read(workbookName) + if err != nil { + return nil, 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, nil, readErr + } + var relationships xlsxRelationshipsDocument + if err := xml.Unmarshal(relationshipsData, &relationships); err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", relationshipsName, err) + } + for _, relationship := range relationships.Items { + targets[relationship.ID] = strings.ToLower(xlsxResolveRelationshipTarget(workbookName, relationship.Target)) + } + } + worksheetParts := make(map[string]bool, len(worksheets)) + for _, name := range worksheets { + worksheetParts[strings.ToLower(name)] = true + } + visibleParts := make(map[string]bool) + hiddenParts := make(map[string]bool) + sheetCount := 0 + var sheetParts []string + settingsByIndex := make(map[int]xlsxPrintSettings) + var sheetNames []string + date1904 := false + unresolvedVisibleSheet := false + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + token, tokenErr := decoder.Token() + if tokenErr == io.EOF { + break + } + if tokenErr != nil { + return nil, nil, fmt.Errorf("parse %s: %w", workbookName, tokenErr) + } + start, ok := token.(xml.StartElement) + 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 + if hasRelationships { + part = targets[attrValue(start, "id")] + } else if sheetCount <= len(worksheets) { + // Without workbook relationships, sheets map to the ordered + // worksheet parts by position. + part = strings.ToLower(worksheets[sheetCount-1]) + } + sheetParts = append(sheetParts, part) + switch { + case isHidden: + hiddenParts[part] = true + case !worksheetParts[part]: + unresolvedVisibleSheet = true + default: + visibleParts[part] = true + } + } + // When every visible sheet resolves to a worksheet part, render exactly those + // parts so that a hidden sheet with a broken relationship is never printed. + // Otherwise only drop the parts known to be hidden, so no visible sheet is + // lost. A visible chartsheet resolves to no worksheet part and therefore + // selects the second mode. + keepVisibleOnly := hasRelationships && sheetCount > 0 && !unresolvedVisibleSheet + visible := make([]string, 0, len(worksheets)) + for _, name := range worksheets { + key := strings.ToLower(name) + keep := !hiddenParts[key] + if keepVisibleOnly { + keep = visibleParts[key] + } + if keep { + visible = append(visible, name) + } + } + 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 renderTextPages(pages, options), nil + 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 {