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
` +
+ `Hidden row
` +
+ `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 {