diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md new file mode 100644 index 00000000..754fad07 --- /dev/null +++ b/FEATURE_PARITY.md @@ -0,0 +1,345 @@ +# Cross-Language Feature Parity + +Last verified: 2026-09-14 + +This document tracks implementation gaps between MiniPdf for .NET, Rust, +Java, Go, Python, and Node.js. It is an engineering backlog, not a claim of +complete Microsoft Office compatibility. + +.NET is the current functional reference because it has the broadest renderer +and compatibility coverage. It is not automatically the correct behavior: a +shared security or conformance gap can exist in every implementation. + +## Status Legend + +| Mark | Meaning | +|---|---| +| `I` | Implemented with source and test or benchmark evidence | +| `P` | Partially implemented or materially narrower than the .NET reference | +| `M` | Missing or currently ineffective | +| `B` | Node.js behavior inherited from the Rust engine | +| `?` | Evidence is insufficient; verify before changing the status | + +## Summary Matrix + +| Capability | .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:|---:| +| XLSX to PDF | I | P | P | P | P | B | +| DOCX to PDF | I | P | P | P | P | B | +| PPTX to PDF | P | P | P | P | P | B | +| Path input and file output | I | I | I | I | I | B | +| In-memory input and PDF bytes | I | I | I | I | I | B | +| Stream input and output | I | M | M | I | M | M | +| Format detection API | Internal | I | I | I | I | B | +| Page-size override | DOCX only | I | I | I | I | B | +| Margin override | I | M | M | I | M | M | +| XLSX conversion controls | I | M | M | P | M | M | +| PDF compression option | I | M | M | I | M | M | +| Missing-font diagnostics | I | M | M | M | M | M | +| Effective custom font embedding | I | I | I | I | M | B | +| Native CLI | I | I | I | I | I | M | +| Tracked visual benchmark evidence | I | P | P | M | M | M | +| Bounded OOXML package loading | M | M | I | I | I | B/M | + +Notes: + +- Node.js is a native binding over the Rust engine. Rendering fixes normally + belong in `minipdf-rs`; Node-specific work should focus on API exposure, + asynchronous execution, packaging, and binding tests. +- `P` does not mean the same depth in every language. The detailed matrices + below identify the important differences. +- The public .NET page-size and margin overrides currently apply to DOCX. The + other implementations expose a common page-size override for all formats. + +## Public API and CLI Gaps + +The .NET reference exposes stream conversion, diagnostics, PDF compression, +DOCX margins, sheet selection, XLSX limits, orientation, fit-to-page controls, +print scale, rows-per-page, and culture-aware value formatting in +[`MiniPdf.cs`](src/MiniPdf/MiniPdf.cs). + +| Gap relative to .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:| +| Input/output stream API | M | M | I | M | M | +| Conversion diagnostics | M | M | M | M | M | +| PDF stream compression option | M | M | I | M | M | +| DOCX margin override | M | M | I | M | M | +| XLSX sheet selection | M | M | M | M | M | +| XLSX row/column limits | M | M | I | M | M | +| XLSX orientation/fit/scale controls | M | M | P | M | M | +| Culture-aware XLSX formatting | M | M | M | M | M | +| Font clear API | M | I | I | M | M | +| Registered-font list API | I | I | I | I | I | +| CLI font directory | I | I | I | M | M | +| CLI advanced XLSX options | M | M | P | M | M | +| Native CLI distribution | I | I | I | I | M | +| Non-blocking/async conversion API | M | M | M | M | M | + +Evidence: + +- Rust currently exposes only `page_size` in + [`ConversionOptions`](minipdf-rs/crates/minipdf/src/lib.rs). +- Java currently exposes only `pageSize` in + [`ConversionOptions`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/ConversionOptions.java). +- Go currently exposes page size, DOCX margins, PDF compression, XLSX row and + column limits, and XLSX orientation in + [`ConversionOptions`](minipdf-go/minipdf.go). +- Python currently exposes only `page_size` in + [`ConversionOptions`](minipdf-python/src/minipdf/options.py). +- Node.js exposes synchronous Rust conversion bindings in + [`lib.rs`](minipdf-node/src/lib.rs); callers must use a worker thread to avoid + blocking the Node.js event loop. + +Go also provides bounded `io.Reader` input, `io.Writer` output, and complete +register/list/clear font lifecycle APIs. Conversion still builds the PDF bytes +in memory before writing them. + +Before adding every .NET option to every language, define a versioned common +options contract. Format-specific options should be capability-gated and +should fail clearly when used with the wrong input format. + +## DOCX Rendering Gaps + +| Feature | .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:|---:| +| Paragraphs, runs, tabs, line breaks | I | I | P | P | I | B | +| Bold, italic, size, color, underline | I | P | M | M | P | B | +| Paragraph spacing and alignment | I | P | M | M | P | B | +| Explicit page breaks | I | I | P | I | I | B | +| Section page size and margins | I | I | P | I | I | B | +| Tables and cell borders | I | P | M | M | M | B | +| Merged table cells | I | P | M | M | M | B | +| Lists and numbering | I | P | M | M | M | B | +| Headers and footers | I | M | M | M | M | B/M | +| Footnotes/endnotes | I | M | M | M | M | B/M | +| Columns | I | M | M | M | M | B/M | +| Images and VML drawings | I | P | M | M | M | B | +| Floating/anchored objects | P | P | M | M | M | B | +| TOC and field-result layout | P | M | M | M | M | B/M | +| CJK and RTL shaping/fallback | I | P | M | M | M | B | + +Primary implementation evidence: + +- .NET: [`DocxReader.cs`](src/MiniPdf/DocxReader.cs) and + [`DocxToPdfConverter.cs`](src/MiniPdf/DocxToPdfConverter.cs) +- Rust: [`docx.rs`](minipdf-rs/crates/minipdf/src/docx.rs) +- Java: [`DocxConverter.java`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/docx/DocxConverter.java) +- Go: [`docx.go`](minipdf-go/docx.go) +- Python: [`docx.py`](minipdf-python/src/minipdf/docx.py) + +## XLSX Rendering Gaps + +| 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 | +| Formula cached values | I | P | P | M | M | B | +| Fonts, fills, borders, alignment | I | P | P | M | M | B | +| Row heights and column widths | I | I | P | P | M | B | +| Merged cells | I | I | I | M | M | B | +| Hidden rows, columns, and sheets | I | P | P | M | M | B | +| Print area and print titles | I | P | P | M | M | B | +| Paper size, margins, orientation | I | P | P | P | M | B | +| Fit-to-page and print scale | I | P | P | M | M | B | +| Conditional and table styles | P | P | P | M | M | B | +| Raster images | I | P | P | M | M | B | +| VML/vector drawings | I | P | P | M | M | B | +| Charts | P | M | M | M | M | B/M | + +Primary implementation evidence: + +- .NET: [`ExcelReader.cs`](src/MiniPdf/ExcelReader.cs) and + [`ExcelToPdfConverter.cs`](src/MiniPdf/ExcelToPdfConverter.cs) +- 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) +- Python: [`xlsx.py`](minipdf-python/src/minipdf/xlsx.py) + +Java XLSX support is substantially broader than Java DOCX/PPTX support because +it uses Apache POI and PDFBox. It should not be described as uniformly +"text-only" across every Office format. + +## PPTX Rendering Gaps + +PPTX support remains partial in every implementation, including .NET. + +| Feature | .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:|---:| +| Slide dimensions and one page per slide | I | I | I | I | I | B | +| Theme and placeholder text | I | P | M | M | M | B | +| Text style and paragraph layout | P | P | M | M | M | B | +| Basic shapes and connectors | P | P | M | M | M | B | +| Shape transforms and geometry | P | P | M | M | M | B | +| Raster images | I | P | M | M | M | B | +| SVG images | P | P | M | M | M | B | +| Tables | P | P | M | M | M | B | +| SmartArt fallback | P | P | M | M | M | B | +| Charts | M | M | M | M | M | M | +| Animations, transitions, media | M | M | M | M | M | M | + +Primary implementation evidence: + +- .NET: [`PptxReader.cs`](src/MiniPdf/PptxReader.cs) and + [`PptxToPdfConverter.cs`](src/MiniPdf/PptxToPdfConverter.cs) +- Rust: [`pptx.rs`](minipdf-rs/crates/minipdf/src/pptx.rs) +- Java: [`PptxConverter.java`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/pptx/PptxConverter.java) +- Go: [`pptx.go`](minipdf-go/pptx.go) +- Python: [`pptx.py`](minipdf-python/src/minipdf/pptx.py) + +## Font, Image, and PDF Writer Gaps + +| Area | .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:|---:| +| Registered and system font fallback | I | I | P | P | M | B | +| Font embedding and subsetting | I | P | P | P | M | B | +| ToUnicode/CID output | I | P | P | I | M | B | +| JPEG/PNG with transparency | I | P | P for XLSX | M | M | B | +| SVG rendering | P | P for PPTX | M | M | M | B | +| EMF/WMF handling | P | P | P for XLSX | M | M | B | +| Public low-level PDF construction API | M | P | P | P | M | M | + +The low-level PDF API is inconsistent: Rust, Java, and Go expose writer types, +while .NET keeps comparable types internal and Node.js does not bind Rust's +writer. Decide whether PDF construction is a supported product API before +attempting parity. If it is not, make implementation-specific writer types +internal in the next breaking release. + +## OOXML Security Gaps + +Java, Go, and Python now enforce explicit package-loading limits. +Java rejects excessive entry counts, oversized or highly compressed entries, +unsafe paths, duplicate entries, and XML external entities in +[`OoxmlPackage.java`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/OoxmlPackage.java) +and +[`SecureXml.java`](minipdf-java/minipdf/src/main/java/io/github/minisoftware/minipdf/internal/SecureXml.java). +Python applies package limits and path checks in +[`office.py`](minipdf-python/src/minipdf/office.py). +Go applies entry count, entry and total size, expansion ratio, encryption, +duplicate path, and unsafe path checks in +[`office.go`](minipdf-go/office.go), exposed through `ErrInvalidPackage`. + +.NET, Rust, and therefore Node.js still need the same bounded OOXML package +contract. Cross-language conformance fixtures must cover: + +- maximum entry count, per-entry size, and total uncompressed size; +- maximum compression ratio and encrypted-entry rejection; +- duplicate normalized path rejection; +- absolute path, drive prefix, and `..` traversal rejection; +- external relationship and XML DTD/entity blocking; +- consistent error categories and malformed-package tests. + +Security behavior should converge before rendering APIs are expanded. + +## Test and Benchmark Evidence Gaps + +All six implementations have language-specific visual benchmark runners under +[`scripts`](scripts), but tracked result coverage is incomplete. + +| Evidence | .NET | Rust | Java | Go | Python | Node.js | +|---|---:|---:|---:|---:|---:|---:| +| Unit tests for public API | I | I | I | P | P | P | +| Malformed/security fixtures | P | P | I | P | I | B/P | +| Classic XLSX report | I | I | M | M | M | M | +| Classic DOCX report | I | I | M | M | M | M | +| Issue XLSX report | I | I | I | M | M | M | +| Issue DOCX report | I | M | M | M | M | M | +| Issue PPTX report | I | M | M | M | M | M | +| Classic PPTX corpus/report | M | M | M | M | M | M | + +A feature should not move from `P` to `I` based only on a parser or API being +present. Require a focused unit test plus a reproducible visual benchmark case. + +### Go Validation Progress + +- 2026-09-14: a five-case classic XLSX baseline scored `0.8093` and reported a + one-byte page content-stream length error in every candidate PDF. After the + writer fix, the same five cases retained `0.8093` with no PDF structure + errors. See the local + [`before`](artifacts/go-parity-baseline/report/comparison_report.md) and + [`after`](artifacts/go-parity-stream-fixed/report/comparison_report.md) + reports. +- 2026-09-14: removing synthetic `Sheet N` text while preserving worksheet + vertical spacing raised the same five-case average from `0.8093` to `0.9365`. + The empty-workbook case improved from `0.5995` to `1.0`, and no case + regressed. See the local + [`report`](artifacts/go-parity-sheet-spacing/report/comparison_report.md). +- 2026-09-14: nine-column horizontal pagination raised the wide-table case from + `0.6867` with `1/3` pages to `0.9953` with `3/3` pages. The five-case average + reached `0.9975`; an apparent `classic02` text-score fluctuation was confirmed + unrelated because its before/after candidate SHA-256 hashes were identical. + See the local + [`report`](artifacts/go-parity-xlsx-pagination-final/report/comparison_report.md). +- 2026-09-14: parsing native DOCX `w:pgMar` values and adding validated margin + overrides raised the five-case classic DOCX average from `0.9872` to `0.9889`; + all cases improved or held. See the local + [`report`](artifacts/go-parity-docx-margins/report/comparison_report.md). + +## Alignment Backlog + +### P0: Correctness and Security + +- [ ] Define one bounded OOXML package-loading contract and shared malicious + fixtures. +- [ ] Implement the contract in .NET and Rust; Node.js inherits Rust. +- [x] Implement bounded OOXML package loading and `ErrInvalidPackage` in Go. +- [ ] Verify Java and Python against the same fixtures and align error behavior. +- [ ] Fix status documentation drift: [`ROADMAP.md`](ROADMAP.md) still says Rust + PPTX is unsupported and omits Java, Go, Python, and Node.js. +- [ ] Fix [`README.md`](README.md), which lists Python input as DOCX only even + though the Python dispatcher supports XLSX and PPTX. +- [ ] Align the Python package and runtime version declarations in + [`pyproject.toml`](minipdf-python/pyproject.toml) and + [`__init__.py`](minipdf-python/src/minipdf/__init__.py). + +### P1: Rendering Conformance + +- [ ] Publish complete current benchmark reports for every language across + classic XLSX/DOCX and issue XLSX/DOCX/PPTX. +- [ ] Add a shared classic PPTX corpus and report path. +- [ ] Rust: close advanced DOCX gaps, especially headers/footers, lists, + columns, notes, and floating objects. +- [ ] Rust: add XLSX chart rendering or explicitly declare charts out of scope. +- [ ] Java: add DOCX tables/styles/images, then PPTX styles/shapes/images. +- [x] Go: add effective registered TTF embedding with Type0/CID and ToUnicode. +- [ ] Go: add TTF subsetting, TTC support, system fallback, and complex-script + shaping. +- [ ] Go: add XLSX styles/merges/images, DOCX tables/images, and PPTX + shapes/images. +- [ ] Python: add effective font embedding and Unicode shaping before complex + layout. +- [ ] Python: add DOCX tables/images, XLSX styles/merges/images, and PPTX + shapes/images. +- [ ] .NET and Rust: define and test the intended PPTX compatibility boundary, + including explicit non-goals for animation and media. + +### P2: API and Distribution + +- [ ] Define a versioned common conversion-options contract and shared option + validation fixtures. +- [ ] Standardize format detection and error categories. +- [ ] Standardize register/list/clear font lifecycle APIs. +- [ ] Decide whether stream conversion is required in each language or whether + bytes/file APIs are the portable contract. +- [ ] Decide whether low-level PDF writer types are public supported APIs. +- [ ] Add a non-blocking Node.js conversion API or an official worker-thread + helper. +- [ ] Decide whether Node.js requires a CLI; do not duplicate the Rust CLI + without a Node-specific distribution need. +- [ ] Add release gates that run unit tests and the relevant focused visual + benchmark before publishing each package. + +## Definition of Aligned + +A capability is aligned only when all of the following are true: + +1. The public behavior and unsupported cases are documented. +2. Shared valid, malformed, and boundary fixtures pass in every applicable + implementation. +3. Each implementation has a focused unit test for its native API. +4. Visual output is checked against the same Microsoft 365 reference and + LibreOffice auxiliary reference. +5. Page count, text similarity, visual score, and known deviations are recorded. +6. The package release workflow executes the required tests. + +When updating this matrix, link the source, test, benchmark report, and issue or +pull request that justify each status change. diff --git a/minipdf-go/README.md b/minipdf-go/README.md index ecafa6cf..6d4af8a2 100644 --- a/minipdf-go/README.md +++ b/minipdf-go/README.md @@ -35,6 +35,61 @@ Convert in-memory Office package bytes: pdf, err := minipdf.ConvertBytesToPDF(input) ``` +Compress PDF page content streams: + +```go +pdf, err := minipdf.ConvertBytesToPDFWithOptions(input, minipdf.ConversionOptions{ + Compress: true, +}) +``` + +Override DOCX page margins in PDF points: + +```go +margins, err := minipdf.NewMargins(36, 48, 36, 48) +if err != nil { + panic(err) +} +pdf, err := minipdf.ConvertBytesToPDFWithOptions(input, minipdf.ConversionOptions{ + Margins: &margins, +}) +``` + +Margin overrides are rejected for XLSX and PPTX input. + +Limit XLSX output or override worksheet orientation: + +```go +landscape := true +pdf, err := minipdf.ConvertBytesToPDFWithOptions(input, minipdf.ConversionOptions{ + MaxRows: 100, + MaxColumns: 12, + Landscape: &landscape, +}) +``` + +Convert between streams: + +```go +err := minipdf.ConvertReaderToWriter(input, output, minipdf.ConversionOptions{}) +``` + +Register a TrueType font before conversion when built-in PDF fonts do not cover +the document text: + +```go +fontData, err := os.ReadFile("fonts/NotoSans-Regular.ttf") +if err != nil { + panic(err) +} +minipdf.RegisterFont("Noto Sans", fontData) +defer minipdf.ClearRegisteredFonts() +``` + +Registered `.ttf` fonts are embedded as Type0/CID fonts with ToUnicode maps. +Font subsetting, TrueType Collections, automatic system-font discovery, and +complex-script shaping are not yet implemented. + Override the output page size: ```go @@ -55,6 +110,9 @@ minipdf report.docx minipdf data.xlsx -o data.pdf minipdf slides.pptx --paper-size a4 minipdf convert report.docx --page-width 400 --page-height 500 +minipdf report.docx --fonts ./fonts +minipdf report.docx --compress +minipdf data.xlsx --max-rows 100 --max-columns 12 --landscape ``` ## Current Scope @@ -66,8 +124,9 @@ minipdf convert report.docx --page-width 400 --page-height 500 | 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 | Registration API reserved; embedding is not implemented yet | -| Interfaces | Go package and native CLI | +| Fonts | Embedded registered TTF fonts with ToUnicode; no subsetting or shaping yet | +| 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. diff --git a/minipdf-go/cmd/minipdf/main.go b/minipdf-go/cmd/minipdf/main.go index f6820bd8..18afc39e 100644 --- a/minipdf-go/cmd/minipdf/main.go +++ b/minipdf-go/cmd/minipdf/main.go @@ -18,11 +18,16 @@ var ( ) type cliOptions struct { - input string - output string - paperSize string - pageWidth float64 - pageHeight float64 + input string + output string + fontDirectory string + compress bool + maxRows int + maxColumns int + landscape *bool + paperSize string + pageWidth float64 + pageHeight float64 } func main() { @@ -58,6 +63,9 @@ func run(options cliOptions) error { if err != nil { return err } + if err := registerFontsFromDirectory(options.fontDirectory); err != nil { + return err + } output := options.output if output == "" { output = strings.TrimSuffix(options.input, filepath.Ext(options.input)) + ".pdf" @@ -69,6 +77,29 @@ func run(options cliOptions) error { return nil } +func registerFontsFromDirectory(directory string) error { + if directory == "" { + return nil + } + entries, err := os.ReadDir(directory) + if err != nil { + return fmt.Errorf("read font directory %q: %w", directory, err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".ttf") { + continue + } + fontPath := filepath.Join(directory, entry.Name()) + fontData, err := os.ReadFile(fontPath) + if err != nil { + return fmt.Errorf("read font %q: %w", fontPath, err) + } + name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) + minipdf.RegisterFont(name, fontData) + } + return nil +} + func conversionOptions(options cliOptions) (minipdf.ConversionOptions, error) { customPageSize := options.pageWidth != 0 || options.pageHeight != 0 if options.paperSize != "" && customPageSize { @@ -96,7 +127,10 @@ func conversionOptions(options cliOptions) (minipdf.ConversionOptions, error) { } pageSize = &size } - return minipdf.ConversionOptions{PageSize: pageSize}, nil + return minipdf.ConversionOptions{ + PageSize: pageSize, Compress: options.compress, + MaxRows: options.maxRows, MaxColumns: options.maxColumns, Landscape: options.landscape, + }, nil } func parseArguments(arguments []string) (cliOptions, error) { @@ -112,9 +146,21 @@ func parseArguments(arguments []string) (cliOptions, error) { if argument == "--version" { return cliOptions{}, errVersion } + if argument == "--compress" { + options.compress = true + continue + } + if argument == "--landscape" || argument == "--portrait" { + landscape := argument == "--landscape" + if options.landscape != nil && *options.landscape != landscape { + return cliOptions{}, errors.New("use either --landscape or --portrait, not both") + } + options.landscape = &landscape + continue + } name, inlineValue, hasInlineValue := strings.Cut(argument, "=") switch name { - case "-o", "--output", "--paper-size", "--page-width", "--page-height": + case "-o", "--output", "--fonts", "--paper-size", "--page-width", "--page-height", "--max-rows", "--max-columns": value := inlineValue if !hasInlineValue { index++ @@ -126,6 +172,8 @@ func parseArguments(arguments []string) (cliOptions, error) { switch name { case "-o", "--output": options.output = value + case "--fonts": + options.fontDirectory = value case "--paper-size": options.paperSize = value case "--page-width": @@ -140,6 +188,18 @@ func parseArguments(arguments []string) (cliOptions, error) { return cliOptions{}, fmt.Errorf("invalid page height %q", value) } options.pageHeight = height + case "--max-rows": + maximum, err := strconv.Atoi(value) + if err != nil || maximum <= 0 { + return cliOptions{}, fmt.Errorf("invalid maximum row count %q", value) + } + options.maxRows = maximum + case "--max-columns": + maximum, err := strconv.Atoi(value) + if err != nil || maximum <= 0 { + return cliOptions{}, fmt.Errorf("invalid maximum column count %q", value) + } + options.maxColumns = maximum } default: if strings.HasPrefix(argument, "-") { @@ -166,6 +226,12 @@ Usage: Options: -o, --output PATH Output PDF path + --fonts DIR Register .ttf fonts from a directory + --compress Compress PDF page content streams + --max-rows COUNT Maximum XLSX rows to render + --max-columns COUNT Maximum XLSX columns to render + --landscape Render XLSX pages in landscape + --portrait Render XLSX pages in portrait --paper-size SIZE a4 or letter --page-width POINTS Custom page width --page-height POINTS Custom page height diff --git a/minipdf-go/cmd/minipdf/main_test.go b/minipdf-go/cmd/minipdf/main_test.go index ad9323fc..138d37ff 100644 --- a/minipdf-go/cmd/minipdf/main_test.go +++ b/minipdf-go/cmd/minipdf/main_test.go @@ -1,8 +1,13 @@ package main import ( + "bytes" "errors" + "os" + "path/filepath" "testing" + + minipdf "github.com/mini-software/MiniPdf/minipdf-go" ) func TestParseArgumentsAcceptsVersionWithoutInput(t *testing.T) { @@ -18,3 +23,74 @@ func TestConversionOptionsRejectsMixedPageSizes(t *testing.T) { t.Fatal("conversionOptions() accepted preset and custom page sizes") } } + +func TestParseArgumentsAcceptsFontDirectory(t *testing.T) { + options, err := parseArguments([]string{"report.docx", "--fonts", "fonts"}) + if err != nil { + t.Fatal(err) + } + if options.fontDirectory != "fonts" { + t.Fatalf("fontDirectory = %q, want %q", options.fontDirectory, "fonts") + } +} + +func TestParseArgumentsAcceptsCompression(t *testing.T) { + options, err := parseArguments([]string{"report.docx", "--compress"}) + if err != nil { + t.Fatal(err) + } + if !options.compress { + t.Fatal("compress = false, want true") + } + conversion, err := conversionOptions(options) + if err != nil { + t.Fatal(err) + } + if !conversion.Compress { + t.Fatal("ConversionOptions.Compress = false, want true") + } +} + +func TestParseArgumentsAcceptsXLSXControls(t *testing.T) { + options, err := parseArguments([]string{ + "report.xlsx", "--max-rows", "10", "--max-columns", "4", "--landscape", + }) + if err != nil { + t.Fatal(err) + } + conversion, err := conversionOptions(options) + if err != nil { + t.Fatal(err) + } + if conversion.MaxRows != 10 || conversion.MaxColumns != 4 || conversion.Landscape == nil || !*conversion.Landscape { + t.Fatalf("conversion options = %#v", conversion) + } +} + +func TestParseArgumentsRejectsConflictingOrientation(t *testing.T) { + _, err := parseArguments([]string{"report.xlsx", "--landscape", "--portrait"}) + if err == nil { + t.Fatal("parseArguments() accepted conflicting orientation flags") + } +} + +func TestRegisterFontsFromDirectory(t *testing.T) { + minipdf.ClearRegisteredFonts() + t.Cleanup(minipdf.ClearRegisteredFonts) + directory := t.TempDir() + fontData := []byte("test font") + if err := os.WriteFile(filepath.Join(directory, "Example.TTF"), fontData, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "ignored.txt"), []byte("ignored"), 0o600); err != nil { + t.Fatal(err) + } + + if err := registerFontsFromDirectory(directory); err != nil { + t.Fatal(err) + } + fonts := minipdf.RegisteredFonts() + if len(fonts) != 1 || fonts[0].Name != "Example" || !bytes.Equal(fonts[0].Data, fontData) { + t.Fatalf("registered fonts = %#v", fonts) + } +} diff --git a/minipdf-go/docx.go b/minipdf-go/docx.go index 27b455e5..a01df532 100644 --- a/minipdf-go/docx.go +++ b/minipdf-go/docx.go @@ -17,20 +17,45 @@ func convertDOCX(input []byte, options ConversionOptions) ([]byte, error) { if err != nil { return nil, err } - pages, pageSize, err := extractDOCX(documentXML) + pages, pageSize, margins, err := extractDOCX(documentXML) if err != nil { return nil, fmt.Errorf("parse word/document.xml: %w", err) } + effectivePageSize := pageSize + if options.PageSize != nil { + effectivePageSize = *options.PageSize + } + effectiveMargins := margins + if options.Margins != nil { + effectiveMargins = *options.Margins + } + if err := validateDOCXLayout(effectivePageSize, effectiveMargins); err != nil { + return nil, err + } textPages := make([]textPage, len(pages)) for index, lines := range pages { - textPages[index] = textPage{lines: lines, size: pageSize} + textPages[index] = textPage{lines: lines, size: pageSize, margins: margins} } return renderTextPages(textPages, options), nil } -func extractDOCX(data []byte) ([][]string, PageSize, error) { +func validateDOCXLayout(pageSize PageSize, margins Margins) error { + if _, err := NewPageSize(pageSize.Width, pageSize.Height); err != nil { + return err + } + if _, err := NewMargins(margins.Left, margins.Top, margins.Right, margins.Bottom); err != nil { + return err + } + if margins.Left+margins.Right >= pageSize.Width || margins.Top+margins.Bottom >= pageSize.Height { + return fmt.Errorf("%w: margins must leave positive page content dimensions", ErrInvalidInput) + } + return nil +} + +func extractDOCX(data []byte) ([][]string, PageSize, Margins, error) { decoder := xml.NewDecoder(bytes.NewReader(data)) pageSize := PageSizeA4 + margins := Margins{Left: 54, Top: 54, Right: 54, Bottom: 54} pages := [][]string{{}} var paragraph strings.Builder paragraphDepth := 0 @@ -41,7 +66,7 @@ func extractDOCX(data []byte) ([][]string, PageSize, error) { if err.Error() == "EOF" { break } - return nil, PageSize{}, err + return nil, PageSize{}, Margins{}, err } switch element := token.(type) { case xml.StartElement: @@ -51,7 +76,7 @@ func extractDOCX(data []byte) ([][]string, PageSize, error) { case "t": var text string if err := decoder.DecodeElement(&text, &element); err != nil { - return nil, PageSize{}, err + return nil, PageSize{}, Margins{}, err } paragraph.WriteString(text) case "tab": @@ -70,6 +95,14 @@ func extractDOCX(data []byte) ([][]string, PageSize, error) { if widthErr == nil && heightErr == nil && width > 0 && height > 0 { pageSize = PageSize{Width: width / 20, Height: height / 20} } + case "pgMar": + left, leftErr := strconv.ParseFloat(attrValue(element, "left"), 64) + top, topErr := strconv.ParseFloat(attrValue(element, "top"), 64) + right, rightErr := strconv.ParseFloat(attrValue(element, "right"), 64) + bottom, bottomErr := strconv.ParseFloat(attrValue(element, "bottom"), 64) + if leftErr == nil && topErr == nil && rightErr == nil && bottomErr == nil && left >= 0 && top >= 0 && right >= 0 && bottom >= 0 { + margins = Margins{Left: left / 20, Top: top / 20, Right: right / 20, Bottom: bottom / 20} + } } case xml.EndElement: if element.Name.Local == "p" && paragraphDepth > 0 { @@ -87,7 +120,7 @@ func extractDOCX(data []byte) ([][]string, PageSize, error) { if len(pages) == 1 && len(pages[0]) == 0 { pages[0] = append(pages[0], "Empty DOCX document") } - return pages, pageSize, nil + return pages, pageSize, margins, nil } func appendDOCXParagraph(lines *[]string, paragraph string) { diff --git a/minipdf-go/font.go b/minipdf-go/font.go new file mode 100644 index 00000000..efa5f59e --- /dev/null +++ b/minipdf-go/font.go @@ -0,0 +1,213 @@ +package minipdf + +import ( + "bytes" + "fmt" + "sort" + "strings" + "unicode/utf16" + + "golang.org/x/image/font" + "golang.org/x/image/font/sfnt" + "golang.org/x/image/math/fixed" +) + +type embeddedFont struct { + name string + data []byte + font *sfnt.Font + runeToGlyph map[rune]sfnt.GlyphIndex + glyphToRune map[sfnt.GlyphIndex]rune + objectID int +} + +func prepareEmbeddedFont(pages []*PDFPage) *embeddedFont { + for _, registered := range RegisteredFonts() { + parsed, err := sfnt.Parse(registered.Data) + if err != nil { + continue + } + candidate := &embeddedFont{ + name: sanitizePDFFontName(registered.Name), + data: registered.Data, + font: parsed, + runeToGlyph: make(map[rune]sfnt.GlyphIndex), + glyphToRune: make(map[sfnt.GlyphIndex]rune), + } + used := false + for _, page := range pages { + for _, operation := range page.operations { + text, ok := operation.(textOperation) + if !ok || !candidate.canEncode(text.text) { + continue + } + candidate.collect(text.text) + used = true + } + } + if used { + return candidate + } + } + return nil +} + +func (embedded *embeddedFont) canEncode(text string) bool { + var buffer sfnt.Buffer + for _, character := range normalizedFontRunes(text) { + glyph, err := embedded.font.GlyphIndex(&buffer, character) + if err != nil || glyph == 0 { + return false + } + } + return true +} + +func (embedded *embeddedFont) collect(text string) { + var buffer sfnt.Buffer + for _, character := range normalizedFontRunes(text) { + glyph, err := embedded.font.GlyphIndex(&buffer, character) + if err != nil || glyph == 0 { + continue + } + embedded.runeToGlyph[character] = glyph + if _, exists := embedded.glyphToRune[glyph]; !exists { + embedded.glyphToRune[glyph] = character + } + } +} + +func (embedded *embeddedFont) encode(text string) (string, bool) { + var encoded strings.Builder + for _, character := range normalizedFontRunes(text) { + glyph, ok := embedded.runeToGlyph[character] + if !ok { + return "", false + } + fmt.Fprintf(&encoded, "%04X", uint16(glyph)) + } + return encoded.String(), true +} + +func normalizedFontRunes(text string) []rune { + characters := []rune(text) + for index, character := range characters { + if character == '\n' || character == '\r' || character == '\t' { + characters[index] = ' ' + } + } + return characters +} + +func appendEmbeddedFontObjects(objects *[][]byte, embedded *embeddedFont) int { + fontFile := fmt.Appendf(nil, "<< /Length %d /Length1 %d >>\nstream\n", len(embedded.data), len(embedded.data)) + fontFile = append(fontFile, embedded.data...) + fontFile = append(fontFile, []byte("\nendstream")...) + fontFileID := appendPDFObject(objects, fontFile) + + unitsPerEm := int64(embedded.font.UnitsPerEm()) + fontBounds := "-1000 -1000 2000 2000" + ascent := int64(800) + descent := int64(-200) + capHeight := int64(700) + if unitsPerEm > 0 { + ppem := fixed.Int26_6(embedded.font.UnitsPerEm()) + if bounds, err := embedded.font.Bounds(nil, ppem, font.HintingNone); err == nil { + fontBounds = fmt.Sprintf("%d %d %d %d", + int64(bounds.Min.X)*1000/unitsPerEm, + -int64(bounds.Max.Y)*1000/unitsPerEm, + int64(bounds.Max.X)*1000/unitsPerEm, + -int64(bounds.Min.Y)*1000/unitsPerEm, + ) + } + if metrics, err := embedded.font.Metrics(nil, ppem, font.HintingNone); err == nil { + ascent = int64(metrics.Ascent) * 1000 / unitsPerEm + descent = -int64(metrics.Descent) * 1000 / unitsPerEm + capHeight = int64(metrics.CapHeight) * 1000 / unitsPerEm + } + } + + descriptorID := appendPDFObject(objects, []byte(fmt.Sprintf( + "<< /Type /FontDescriptor /FontName /%s /Flags 32 /FontBBox [%s] /ItalicAngle 0 /Ascent %d /Descent %d /CapHeight %d /StemV 80 /FontFile2 %d 0 R >>", + embedded.name, fontBounds, ascent, descent, capHeight, fontFileID, + ))) + + glyphs := make([]int, 0, len(embedded.glyphToRune)) + for glyph := range embedded.glyphToRune { + glyphs = append(glyphs, int(glyph)) + } + sort.Ints(glyphs) + widths := make([]string, 0, len(glyphs)) + for _, glyph := range glyphs { + width := int64(1000) + if unitsPerEm > 0 { + advance, err := embedded.font.GlyphAdvance(nil, sfnt.GlyphIndex(glyph), fixed.Int26_6(embedded.font.UnitsPerEm()), font.HintingNone) + if err == nil { + width = int64(advance) * 1000 / unitsPerEm + } + } + widths = append(widths, fmt.Sprintf("%d [%d]", glyph, width)) + } + cidFontID := appendPDFObject(objects, []byte(fmt.Sprintf( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /%s /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor %d 0 R /DW 1000 /W [%s] /CIDToGIDMap /Identity >>", + embedded.name, descriptorID, strings.Join(widths, " "), + ))) + + toUnicode := buildToUnicodeCMap(embedded.glyphToRune) + toUnicodeObject := fmt.Appendf(nil, "<< /Length %d >>\nstream\n", len(toUnicode)) + toUnicodeObject = append(toUnicodeObject, toUnicode...) + toUnicodeObject = append(toUnicodeObject, []byte("\nendstream")...) + toUnicodeID := appendPDFObject(objects, toUnicodeObject) + + return appendPDFObject(objects, []byte(fmt.Sprintf( + "<< /Type /Font /Subtype /Type0 /BaseFont /%s /Encoding /Identity-H /DescendantFonts [%d 0 R] /ToUnicode %d 0 R >>", + embedded.name, cidFontID, toUnicodeID, + ))) +} + +func buildToUnicodeCMap(glyphToRune map[sfnt.GlyphIndex]rune) []byte { + glyphs := make([]int, 0, len(glyphToRune)) + for glyph := range glyphToRune { + glyphs = append(glyphs, int(glyph)) + } + sort.Ints(glyphs) + + var cmap bytes.Buffer + cmap.WriteString("/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n") + for start := 0; start < len(glyphs); start += 100 { + end := min(start+100, len(glyphs)) + fmt.Fprintf(&cmap, "%d beginbfchar\n", end-start) + for _, glyph := range glyphs[start:end] { + fmt.Fprintf(&cmap, "<%04X> <%s>\n", glyph, utf16Hex(glyphToRune[sfnt.GlyphIndex(glyph)])) + } + cmap.WriteString("endbfchar\n") + } + cmap.WriteString("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend") + return cmap.Bytes() +} + +func utf16Hex(character rune) string { + var encoded strings.Builder + for _, unit := range utf16.Encode([]rune{character}) { + fmt.Fprintf(&encoded, "%04X", unit) + } + return encoded.String() +} + +func sanitizePDFFontName(name string) string { + var sanitized strings.Builder + for _, character := range name { + if character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' { + sanitized.WriteRune(character) + } + } + if sanitized.Len() == 0 { + return "MiniPdfFont" + } + return sanitized.String() +} + +func appendPDFObject(objects *[][]byte, object []byte) int { + *objects = append(*objects, object) + return len(*objects) +} diff --git a/minipdf-go/go.mod b/minipdf-go/go.mod index 810a694c..9279b431 100644 --- a/minipdf-go/go.mod +++ b/minipdf-go/go.mod @@ -1,3 +1,7 @@ module github.com/mini-software/MiniPdf/minipdf-go -go 1.22 \ No newline at end of file +go 1.22 + +require golang.org/x/image v0.24.0 + +require golang.org/x/text v0.22.0 // indirect diff --git a/minipdf-go/go.sum b/minipdf-go/go.sum new file mode 100644 index 00000000..4fe2ad0e --- /dev/null +++ b/minipdf-go/go.sum @@ -0,0 +1,4 @@ +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= diff --git a/minipdf-go/minipdf.go b/minipdf-go/minipdf.go index 3b26352d..47e78a0c 100644 --- a/minipdf-go/minipdf.go +++ b/minipdf-go/minipdf.go @@ -1,10 +1,10 @@ package minipdf import ( - "archive/zip" "bytes" "errors" "fmt" + "io" "math" "os" "path/filepath" @@ -14,6 +14,8 @@ import ( var ( ErrUnsupportedFormat = errors.New("unsupported or unknown Office document format") + ErrInvalidPackage = errors.New("invalid Office package") + ErrInvalidInput = errors.New("invalid input") PageSizeA4 = PageSize{Width: 595.28, Height: 841.89} PageSizeLetter = PageSize{Width: 612, Height: 792} ) @@ -34,13 +36,41 @@ type PageSize struct { func NewPageSize(width, height float64) (PageSize, error) { if math.IsNaN(width) || math.IsNaN(height) || math.IsInf(width, 0) || math.IsInf(height, 0) || width <= 0 || height <= 0 { - return PageSize{}, errors.New("page width and height must be positive finite values") + return PageSize{}, fmt.Errorf("%w: page width and height must be positive finite values", ErrInvalidInput) } return PageSize{Width: width, Height: height}, nil } +type Margins struct { + Left float64 + Top float64 + Right float64 + Bottom float64 +} + +// NewMargins creates validated page margins measured in PDF points. +func NewMargins(left, top, right, bottom float64) (Margins, error) { + values := []float64{left, top, right, bottom} + for _, value := range values { + if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 { + return Margins{}, fmt.Errorf("%w: margins must be non-negative finite values", ErrInvalidInput) + } + } + return Margins{Left: left, Top: top, Right: right, Bottom: bottom}, nil +} + type ConversionOptions struct { PageSize *PageSize + // Margins overrides DOCX page margins in PDF points. + Margins *Margins + // Compress applies Flate compression to PDF page content streams. + Compress bool + // MaxRows limits rendered worksheet rows. Zero leaves rows unlimited. + MaxRows int + // MaxColumns limits rendered worksheet columns. Zero leaves columns unlimited. + MaxColumns int + // Landscape overrides XLSX worksheet orientation when non-nil. + Landscape *bool } type RegisteredFont struct { @@ -69,13 +99,48 @@ func RegisteredFonts() []RegisteredFont { return fonts } +// ClearRegisteredFonts removes all process-wide font registrations. +func ClearRegisteredFonts() { + fontRegistry.Lock() + defer fontRegistry.Unlock() + fontRegistry.fonts = nil +} + +// ConvertReaderToPDF reads an Office package and returns the converted PDF. +func ConvertReaderToPDF(input io.Reader) ([]byte, error) { + return ConvertReaderToPDFWithOptions(input, ConversionOptions{}) +} + +// ConvertReaderToPDFWithOptions reads an Office package and returns the converted PDF. +func ConvertReaderToPDFWithOptions(input io.Reader, options ConversionOptions) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(input, int64(defaultOfficePackageLimits.maxTotalSize)+1)) + if err != nil { + return nil, fmt.Errorf("read input: %w", err) + } + if uint64(len(data)) > defaultOfficePackageLimits.maxTotalSize { + return nil, fmt.Errorf("%w: input exceeds the configured size limit", ErrInvalidPackage) + } + return ConvertBytesToPDFWithOptions(data, options) +} + +// ConvertReaderToWriter converts an Office package and writes the PDF to output. +func ConvertReaderToWriter(input io.Reader, output io.Writer, options ConversionOptions) error { + pdf, err := ConvertReaderToPDFWithOptions(input, options) + if err != nil { + return err + } + if _, err := io.Copy(output, bytes.NewReader(pdf)); err != nil { + return fmt.Errorf("write PDF: %w", err) + } + return nil +} + func DetectOfficeFormat(input []byte) (OfficeFormat, error) { - reader, err := zip.NewReader(bytes.NewReader(input), int64(len(input))) + files, err := openOfficePackage(input) if err != nil { - return OfficeFormatUnknown, fmt.Errorf("open Office package: %w", err) + return OfficeFormatUnknown, err } - for _, file := range reader.File { - name := strings.ReplaceAll(file.Name, `\`, "/") + for name := range files { switch { case strings.HasPrefix(name, "word/"): return OfficeFormatDOCX, nil @@ -145,6 +210,15 @@ func convertBytesAs(input []byte, format OfficeFormat, options ConversionOptions } format = detected } + if options.Margins != nil && format != OfficeFormatDOCX { + return nil, fmt.Errorf("%w: margin overrides apply only to DOCX input", ErrInvalidInput) + } + if options.MaxRows < 0 || options.MaxColumns < 0 { + return nil, fmt.Errorf("%w: XLSX row and column limits cannot be negative", ErrInvalidInput) + } + if format != OfficeFormatXLSX && (options.MaxRows != 0 || options.MaxColumns != 0 || options.Landscape != nil) { + return nil, fmt.Errorf("%w: worksheet limits and orientation apply only to XLSX input", ErrInvalidInput) + } switch format { case OfficeFormatDOCX: return convertDOCX(input, options) diff --git a/minipdf-go/minipdf_test.go b/minipdf-go/minipdf_test.go index 3943edb9..9d38bbf4 100644 --- a/minipdf-go/minipdf_test.go +++ b/minipdf-go/minipdf_test.go @@ -4,10 +4,69 @@ import ( "archive/zip" "bytes" "errors" + "io" "math" "testing" ) +func TestClearRegisteredFonts(t *testing.T) { + ClearRegisteredFonts() + t.Cleanup(ClearRegisteredFonts) + RegisterFont("Example", []byte("font data")) + + ClearRegisteredFonts() + + if fonts := RegisteredFonts(); len(fonts) != 0 { + t.Fatalf("RegisteredFonts() returned %d fonts after clear", len(fonts)) + } +} + +func TestConvertReaderToWriter(t *testing.T) { + input := zipPackage(t, "word/document.xml", `Hello stream`) + expected, err := ConvertBytesToPDF(input) + if err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + if err := ConvertReaderToWriter(bytes.NewReader(input), &output, ConversionOptions{}); err != nil { + t.Fatal(err) + } + if !bytes.Equal(output.Bytes(), expected) { + t.Fatal("stream conversion differs from byte conversion") + } +} + +func TestConvertReaderToWriterPropagatesIOErrors(t *testing.T) { + _, err := ConvertReaderToPDF(errorReader{}) + if !errors.Is(err, errTestIO) { + t.Fatalf("ConvertReaderToPDF() error = %v, want errTestIO", err) + } + + input := zipPackage(t, "word/document.xml", ``) + err = ConvertReaderToWriter(bytes.NewReader(input), errorWriter{}, ConversionOptions{}) + if !errors.Is(err, errTestIO) { + t.Fatalf("ConvertReaderToWriter() error = %v, want errTestIO", err) + } +} + +var errTestIO = errors.New("test I/O error") + +type errorReader struct{} + +func (errorReader) Read([]byte) (int, error) { + return 0, errTestIO +} + +type errorWriter struct{} + +func (errorWriter) Write([]byte) (int, error) { + return 0, errTestIO +} + +var _ io.Reader = errorReader{} +var _ io.Writer = errorWriter{} + func TestDetectOfficeFormat(t *testing.T) { tests := []struct { name string @@ -41,6 +100,14 @@ func TestNewPageSizeRejectsInvalidDimensions(t *testing.T) { } } +func TestNewMarginsRejectsInvalidDimensions(t *testing.T) { + for _, margins := range [][4]float64{{-1, 0, 0, 0}, {0, math.Inf(1), 0, 0}} { + if _, err := NewMargins(margins[0], margins[1], margins[2], margins[3]); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("NewMargins%v error = %v, want ErrInvalidInput", margins, err) + } + } +} + func TestUnknownPackageIsUnsupported(t *testing.T) { _, err := ConvertBytesToPDF(zipPackage(t, "custom/data.xml", "")) if !errors.Is(err, ErrUnsupportedFormat) { diff --git a/minipdf-go/office.go b/minipdf-go/office.go index a6eff8ed..2de1b8d9 100644 --- a/minipdf-go/office.go +++ b/minipdf-go/office.go @@ -15,23 +15,96 @@ import ( type officePackage map[string]*zip.File +type officePackageLimits struct { + maxEntries int + maxEntrySize uint64 + maxTotalSize uint64 + maxExpansionRate uint64 +} + +var defaultOfficePackageLimits = officePackageLimits{ + maxEntries: 10_000, + maxEntrySize: 128 * 1024 * 1024, + maxTotalSize: 512 * 1024 * 1024, + maxExpansionRate: 200, +} + type textPage struct { - lines []string - size PageSize + lines []string + size PageSize + margins Margins } func openOfficePackage(input []byte) (officePackage, error) { reader, err := zip.NewReader(bytes.NewReader(input), int64(len(input))) if err != nil { - return nil, fmt.Errorf("open Office package: %w", err) + return nil, fmt.Errorf("%w: open ZIP package: %v", ErrInvalidPackage, err) } - files := make(officePackage, len(reader.File)) - for _, file := range reader.File { - files[strings.ReplaceAll(file.Name, `\`, "/")] = file + return validateOfficePackage(reader.File, uint64(len(input)), defaultOfficePackageLimits) +} + +func validateOfficePackage(entries []*zip.File, inputSize uint64, limits officePackageLimits) (officePackage, error) { + if len(entries) > limits.maxEntries { + return nil, fmt.Errorf("%w: ZIP package contains too many entries", ErrInvalidPackage) + } + files := make(officePackage, len(entries)) + seen := make(map[string]struct{}, len(entries)) + var totalSize uint64 + for _, file := range entries { + name, err := normalizePackagePath(file.Name) + if err != nil { + return nil, err + } + if _, exists := seen[name]; exists { + return nil, fmt.Errorf("%w: ZIP package contains duplicate entry %q", ErrInvalidPackage, name) + } + seen[name] = struct{}{} + if file.Flags&0x1 != 0 { + return nil, fmt.Errorf("%w: encrypted entry %q is not supported", ErrInvalidPackage, name) + } + if file.UncompressedSize64 > limits.maxEntrySize { + return nil, fmt.Errorf("%w: ZIP entry %q expands beyond the configured limit", ErrInvalidPackage, name) + } + if exceedsExpansionRatio(file.UncompressedSize64, file.CompressedSize64, limits.maxExpansionRate) { + return nil, fmt.Errorf("%w: ZIP entry %q exceeds the configured expansion ratio", ErrInvalidPackage, name) + } + if totalSize > limits.maxTotalSize || file.UncompressedSize64 > limits.maxTotalSize-totalSize { + return nil, fmt.Errorf("%w: ZIP package expands beyond the configured limit", ErrInvalidPackage) + } + totalSize += file.UncompressedSize64 + if exceedsExpansionRatio(totalSize, inputSize, limits.maxExpansionRate) { + return nil, fmt.Errorf("%w: ZIP package exceeds the configured expansion ratio", ErrInvalidPackage) + } + if !file.FileInfo().IsDir() { + files[name] = file + } } return files, nil } +func exceedsExpansionRatio(uncompressedSize, compressedSize, maximum uint64) bool { + if uncompressedSize == 0 { + return false + } + if compressedSize == 0 || maximum == 0 { + return true + } + return (uncompressedSize+maximum-1)/maximum > compressedSize +} + +func normalizePackagePath(name string) (string, error) { + normalized := strings.ReplaceAll(name, `\`, "/") + if strings.HasPrefix(normalized, "/") || strings.Contains(normalized, ":") || strings.ContainsRune(normalized, '\x00') { + return "", fmt.Errorf("%w: ZIP package contains unsafe entry path %q", ErrInvalidPackage, name) + } + for _, segment := range strings.Split(normalized, "/") { + if segment == ".." { + return "", fmt.Errorf("%w: ZIP package contains unsafe entry path %q", ErrInvalidPackage, name) + } + } + return normalized, nil +} + func (files officePackage) read(name string) ([]byte, error) { file, ok := files[name] if !ok { @@ -42,10 +115,13 @@ func (files officePackage) read(name string) ([]byte, error) { return nil, fmt.Errorf("open Office package part %q: %w", name, err) } defer reader.Close() - data, err := io.ReadAll(reader) + data, err := io.ReadAll(io.LimitReader(reader, int64(defaultOfficePackageLimits.maxEntrySize)+1)) if err != nil { return nil, fmt.Errorf("read Office package part %q: %w", name, err) } + if uint64(len(data)) > defaultOfficePackageLimits.maxEntrySize { + return nil, fmt.Errorf("read Office package part %q: entry expands beyond the configured limit", name) + } return data, nil } @@ -56,18 +132,24 @@ func renderTextPages(pages []textPage, options ConversionOptions) []byte { if options.PageSize != nil { pageSize = *options.PageSize } - addTextPages(document, sourcePage.lines, pageSize) + margins := sourcePage.margins + if options.Margins != nil { + margins = *options.Margins + } + addTextPages(document, sourcePage.lines, pageSize, margins) } - return document.Bytes() + return document.BytesWithOptions(PDFSaveOptions{Compress: options.Compress}) } -func addTextPages(document *PDFDocument, lines []string, pageSize PageSize) { +func addTextPages(document *PDFDocument, lines []string, pageSize PageSize, margins Margins) { const ( - margin = 54.0 fontSize = 11.0 leading = 15.0 ) - maxCharacters := int((pageSize.Width - margin*2) / (fontSize * 0.52)) + if margins == (Margins{}) { + margins = Margins{Left: 54, Top: 54, Right: 54, Bottom: 54} + } + maxCharacters := int((pageSize.Width - margins.Left - margins.Right) / (fontSize * 0.52)) if maxCharacters < 10 { maxCharacters = 10 } @@ -80,13 +162,13 @@ func addTextPages(document *PDFDocument, lines []string, pageSize PageSize) { } page := document.AddPage(pageSize.Width, pageSize.Height) - y := pageSize.Height - margin + y := pageSize.Height - margins.Top for _, line := range wrapped { - if y < margin { + if y < margins.Bottom { page = document.AddPage(pageSize.Width, pageSize.Height) - y = pageSize.Height - margin + y = pageSize.Height - margins.Top } - page.AddText(line, margin, y, fontSize, PDFColorBlack, false) + 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 a5b46a80..03c473f2 100644 --- a/minipdf-go/office_test.go +++ b/minipdf-go/office_test.go @@ -3,9 +3,109 @@ package minipdf import ( "archive/zip" "bytes" + "errors" + "strings" "testing" ) +func TestOpenOfficePackageRejectsUnsafePaths(t *testing.T) { + input := officePackageEntries(t, []packageEntry{ + {name: "word/document.xml", content: ""}, + {name: "../escape.xml", content: ""}, + }) + + _, err := openOfficePackage(input) + assertPackageErrorContains(t, err, "unsafe entry path") + if !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("error = %v, want ErrInvalidPackage", err) + } +} + +func TestOpenOfficePackageRejectsDuplicateNormalizedPaths(t *testing.T) { + input := officePackageEntries(t, []packageEntry{ + {name: "word/document.xml", content: ""}, + {name: `word\document.xml`, content: ""}, + }) + + _, err := openOfficePackage(input) + assertPackageErrorContains(t, err, "duplicate entry") +} + +func TestValidateOfficePackageLimits(t *testing.T) { + baseLimits := officePackageLimits{ + maxEntries: 10, + maxEntrySize: 100, + maxTotalSize: 200, + maxExpansionRate: 10, + } + tests := []struct { + name string + entries []*zip.File + inputSize uint64 + limits officePackageLimits + message string + }{ + { + name: "entry count", + entries: []*zip.File{ + zipFile("word/document.xml", 1, 1, 0), + zipFile("word/styles.xml", 1, 1, 0), + }, + inputSize: 2, + limits: officePackageLimits{maxEntries: 1, maxEntrySize: 100, maxTotalSize: 200, maxExpansionRate: 10}, + message: "too many entries", + }, + { + name: "entry size", + entries: []*zip.File{zipFile("word/document.xml", 10, 101, 0)}, + inputSize: 10, + limits: baseLimits, + message: "entry \"word/document.xml\" expands beyond", + }, + { + name: "total size", + entries: []*zip.File{ + zipFile("word/document.xml", 60, 60, 0), + zipFile("word/styles.xml", 60, 60, 0), + }, + inputSize: 120, + limits: officePackageLimits{maxEntries: 10, maxEntrySize: 100, maxTotalSize: 100, maxExpansionRate: 10}, + message: "package expands beyond", + }, + { + name: "entry expansion ratio", + entries: []*zip.File{zipFile("word/document.xml", 10, 101, 0)}, + inputSize: 101, + limits: officePackageLimits{maxEntries: 10, maxEntrySize: 200, maxTotalSize: 200, maxExpansionRate: 10}, + message: "entry \"word/document.xml\" exceeds", + }, + { + name: "package expansion ratio", + entries: []*zip.File{zipFile("word/document.xml", 30, 30, 0)}, + inputSize: 2, + limits: baseLimits, + message: "package exceeds", + }, + { + name: "encrypted entry", + entries: []*zip.File{zipFile("word/document.xml", 1, 1, 0x1)}, + inputSize: 1, + limits: baseLimits, + message: "encrypted entry", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := validateOfficePackage(test.entries, test.inputSize, test.limits) + assertPackageErrorContains(t, err, test.message) + if !errors.Is(err, ErrInvalidPackage) { + t.Fatalf("error = %v, want ErrInvalidPackage", err) + } + }) + } +} + func TestConvertDOCXToPDF(t *testing.T) { input := officePackageBytes(t, map[string]string{ "word/document.xml": `Hello DOCX`, @@ -15,6 +115,64 @@ func TestConvertDOCXToPDF(t *testing.T) { assertPDFContains(t, pdf, err, "Hello DOCX", "/MediaBox [0 0 612 792]") } +func TestConversionCompressionOption(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "word/document.xml": `Hello compressed DOCX`, + }) + + pdf, err := ConvertBytesToPDFWithOptions(input, ConversionOptions{Compress: true}) + assertPDFContains(t, pdf, err, "/Filter /FlateDecode") +} + +func TestDOCXSectionMarginsAndOverride(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "word/document.xml": `Margin text`, + }) + + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "72 756 Td") + + margins, err := NewMargins(20, 30, 40, 50) + if err != nil { + t.Fatal(err) + } + pdf, err = ConvertBytesToPDFWithOptions(input, ConversionOptions{Margins: &margins}) + assertPDFContains(t, pdf, err, "20 762 Td") +} + +func TestDOCXMarginOverrideIsFormatSpecific(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/worksheets/sheet1.xml": ``, + }) + margins, err := NewMargins(20, 30, 40, 50) + if err != nil { + t.Fatal(err) + } + + _, err = ConvertBytesToPDFWithOptions(input, ConversionOptions{Margins: &margins}) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("error = %v, want ErrInvalidInput", err) + } +} + +func TestDOCXMarginOverrideRejectsInvalidLayout(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "word/document.xml": `Margin text`, + }) + invalidMargins := []Margins{ + {Left: -1}, + {Left: 400, Right: 300}, + {Top: 500, Bottom: 400}, + } + for _, margins := range invalidMargins { + _, err := ConvertBytesToPDFWithOptions(input, ConversionOptions{Margins: &margins}) + if !errors.Is(err, ErrInvalidInput) { + t.Fatalf("margins = %#v, error = %v, want ErrInvalidInput", margins, err) + } + } +} + func TestConvertXLSXToPDF(t *testing.T) { input := officePackageBytes(t, map[string]string{ "xl/workbook.xml": ``, @@ -25,6 +183,63 @@ func TestConvertXLSXToPDF(t *testing.T) { pdf, err := ConvertBytesToPDF(input) assertPDFContains(t, pdf, err, "Hello XLSX", "Cell B", "/MediaBox [0 0 792 612]") + if bytes.Contains(pdf, []byte("Sheet 1")) { + t.Fatal("PDF contains a synthetic worksheet title") + } +} + +func TestSplitWorksheetColumnGroups(t *testing.T) { + lines := []string{ + "A\tB\tC\tD\tE\tF\tG\tH\tI\tJ", + "A1\tB1\tC1\tD1\tE1\tF1\tG1\tH1\tI1\tJ1", + } + + groups := splitWorksheetColumnGroups(lines, 9) + + if len(groups) != 2 { + t.Fatalf("group count = %d, want 2", len(groups)) + } + if groups[0][0] != "A\tB\tC\tD\tE\tF\tG\tH\tI" || groups[1][0] != "J" { + t.Fatalf("groups = %#v", groups) + } +} + +func TestXLSXRowColumnLimitsAndOrientation(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/worksheets/sheet1.xml": `` + + `A1B1C1` + + `A2` + + ``, + }) + landscape := true + + pdf, err := ConvertBytesToPDFWithOptions(input, ConversionOptions{ + MaxRows: 1, MaxColumns: 2, Landscape: &landscape, + }) + assertPDFContains(t, pdf, err, "A1", "B1", "/MediaBox [0 0 792 612]") + for _, excluded := range []string{"C1", "A2"} { + if bytes.Contains(pdf, []byte(excluded)) { + t.Errorf("PDF contains excluded value %q", excluded) + } + } +} + +func TestXLSXOptionsRejectInvalidValuesAndFormats(t *testing.T) { + xlsx := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/worksheets/sheet1.xml": ``, + }) + if _, err := ConvertBytesToPDFWithOptions(xlsx, ConversionOptions{MaxRows: -1}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("MaxRows error = %v, want ErrInvalidInput", err) + } + + docx := officePackageBytes(t, map[string]string{ + "word/document.xml": ``, + }) + if _, err := ConvertBytesToPDFWithOptions(docx, ConversionOptions{MaxColumns: 1}); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("DOCX MaxColumns error = %v, want ErrInvalidInput", err) + } } func TestConvertPPTXToPDF(t *testing.T) { @@ -57,15 +272,38 @@ func assertPDFContains(t *testing.T, pdf []byte, err error, values ...string) { } func officePackageBytes(t *testing.T, entries map[string]string) []byte { + t.Helper() + packageEntries := make([]packageEntry, 0, len(entries)) + for name, content := range entries { + packageEntries = append(packageEntries, packageEntry{name: name, content: content}) + } + return officePackageEntries(t, packageEntries) +} + +type packageEntry struct { + name string + content string +} + +func zipFile(name string, compressedSize, uncompressedSize uint64, flags uint16) *zip.File { + return &zip.File{FileHeader: zip.FileHeader{ + Name: name, + Flags: flags, + CompressedSize64: compressedSize, + UncompressedSize64: uncompressedSize, + }} +} + +func officePackageEntries(t *testing.T, entries []packageEntry) []byte { t.Helper() var buffer bytes.Buffer writer := zip.NewWriter(&buffer) - for name, content := range entries { - file, err := writer.Create(name) + for _, entry := range entries { + file, err := writer.Create(entry.name) if err != nil { t.Fatal(err) } - if _, err := file.Write([]byte(content)); err != nil { + if _, err := file.Write([]byte(entry.content)); err != nil { t.Fatal(err) } } @@ -74,3 +312,13 @@ func officePackageBytes(t *testing.T, entries map[string]string) []byte { } return buffer.Bytes() } + +func assertPackageErrorContains(t *testing.T, err error, message string) { + t.Helper() + if err == nil { + t.Fatalf("expected package error containing %q", message) + } + if !strings.Contains(err.Error(), message) { + t.Fatalf("error = %q, want message containing %q", err, message) + } +} diff --git a/minipdf-go/pdf.go b/minipdf-go/pdf.go index e67694db..5d37b60d 100644 --- a/minipdf-go/pdf.go +++ b/minipdf-go/pdf.go @@ -2,6 +2,7 @@ package minipdf import ( "bytes" + "compress/zlib" "fmt" "strconv" "strings" @@ -22,13 +23,18 @@ var ( ) type pdfOperation interface { - appendPDF(*bytes.Buffer) + appendPDF(*bytes.Buffer, *embeddedFont) } type PDFDocument struct { pages []*PDFPage } +// PDFSaveOptions controls PDF serialization. +type PDFSaveOptions struct { + Compress bool +} + type PDFPage struct { Width float64 Height float64 @@ -70,7 +76,21 @@ type textOperation struct { bold bool } -func (operation textOperation) appendPDF(buffer *bytes.Buffer) { +func (operation textOperation) appendPDF(buffer *bytes.Buffer, embedded *embeddedFont) { + if embedded != nil { + if encoded, ok := embedded.encode(operation.text); ok { + fmt.Fprintf(buffer, "BT /FU1 %s Tf %s %s %s rg %s %s Td <%s> Tj ET\n", + pdfNumber(operation.fontSize), + pdfNumber(operation.color.Red), + pdfNumber(operation.color.Green), + pdfNumber(operation.color.Blue), + pdfNumber(operation.x), + pdfNumber(operation.y), + encoded, + ) + return + } + } font := "F1" if operation.bold { font = "F2" @@ -92,7 +112,7 @@ type rectOperation struct { color PDFColor } -func (operation rectOperation) appendPDF(buffer *bytes.Buffer) { +func (operation rectOperation) appendPDF(buffer *bytes.Buffer, _ *embeddedFont) { fmt.Fprintf(buffer, "%s %s %s rg %s %s %s %s re f\n", pdfNumber(operation.color.Red), pdfNumber(operation.color.Green), @@ -110,7 +130,7 @@ type lineOperation struct { width float64 } -func (operation lineOperation) appendPDF(buffer *bytes.Buffer) { +func (operation lineOperation) appendPDF(buffer *bytes.Buffer, _ *embeddedFont) { fmt.Fprintf(buffer, "%s %s %s RG %s w %s %s m %s %s l S\n", pdfNumber(operation.color.Red), pdfNumber(operation.color.Green), @@ -124,35 +144,62 @@ func (operation lineOperation) appendPDF(buffer *bytes.Buffer) { } func (document *PDFDocument) Bytes() []byte { + return document.BytesWithOptions(PDFSaveOptions{}) +} + +// BytesWithOptions serializes the document with the requested save behavior. +func (document *PDFDocument) BytesWithOptions(options PDFSaveOptions) []byte { pages := document.pages if len(pages) == 0 { pages = []*PDFPage{{Width: PageSizeA4.Width, Height: PageSizeA4.Height}} } pageCount := len(pages) - objects := make([][]byte, 4+pageCount*2) + 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) + if embedded != nil { + embedded.objectID = appendEmbeddedFontObjects(&objects, embedded) + } + pageObjectStart := len(objects) + 1 pageReferences := make([]string, pageCount) for index := range pages { - pageReferences[index] = fmt.Sprintf("%d 0 R", 5+index*2) + pageReferences[index] = fmt.Sprintf("%d 0 R", pageObjectStart+index*2) } objects[1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", pageCount, strings.Join(pageReferences, " "))) - objects[2] = []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") - objects[3] = []byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") for index, page := range pages { - pageObjectNumber := 5 + index*2 + pageObjectNumber := pageObjectStart + index*2 contentObjectNumber := pageObjectNumber + 1 - objects[pageObjectNumber-1] = []byte(fmt.Sprintf( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents %d 0 R >>", - pdfNumber(page.Width), pdfNumber(page.Height), contentObjectNumber, + fontResources := "/F1 3 0 R /F2 4 0 R" + if embedded != nil { + fontResources += fmt.Sprintf(" /FU1 %d 0 R", embedded.objectID) + } + pageObject := []byte(fmt.Sprintf( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %s %s] /Resources << /Font << %s >> >> /Contents %d 0 R >>", + pdfNumber(page.Width), pdfNumber(page.Height), fontResources, contentObjectNumber, )) var content bytes.Buffer for _, operation := range page.operations { - operation.appendPDF(&content) + operation.appendPDF(&content, embedded) + } + contentData := content.Bytes() + filter := "" + if options.Compress { + var compressed bytes.Buffer + writer := zlib.NewWriter(&compressed) + _, _ = writer.Write(contentData) + _ = writer.Close() + contentData = compressed.Bytes() + filter = " /Filter /FlateDecode" } - objects[contentObjectNumber-1] = []byte(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", content.Len(), content.String())) + contentObject := fmt.Appendf(nil, "<< /Length %d%s >>\nstream\n", len(contentData), filter) + contentObject = append(contentObject, contentData...) + contentObject = append(contentObject, []byte("\nendstream")...) + objects = append(objects, pageObject, contentObject) } var output bytes.Buffer diff --git a/minipdf-go/pdf_test.go b/minipdf-go/pdf_test.go index a8167551..4eaf3657 100644 --- a/minipdf-go/pdf_test.go +++ b/minipdf-go/pdf_test.go @@ -2,11 +2,77 @@ package minipdf import ( "bytes" + "compress/zlib" + "io" "regexp" "strconv" "testing" + + "golang.org/x/image/font/gofont/goregular" ) +func TestPDFDocumentCompressesContentStreams(t *testing.T) { + document := NewPDFDocument() + page := document.AddPage(300, 400) + for index := 0; index < 100; index++ { + page.AddText("Repeated content for compression", 20, float64(350-index), 12, PDFColorBlack, false) + } + + uncompressed := document.Bytes() + compressed := document.BytesWithOptions(PDFSaveOptions{Compress: true}) + + if !bytes.Contains(compressed, []byte("/Filter /FlateDecode")) { + t.Fatal("compressed PDF does not declare FlateDecode") + } + assertPDFStreamLengths(t, compressed) + if len(compressed) >= len(uncompressed) { + t.Fatalf("compressed PDF size = %d, want less than %d", len(compressed), len(uncompressed)) + } + streamStart := bytes.Index(compressed, []byte("stream\n")) + streamEnd := bytes.Index(compressed[streamStart+len("stream\n"):], []byte("\nendstream")) + if streamStart < 0 || streamEnd < 0 { + t.Fatal("compressed content stream is missing") + } + streamStart += len("stream\n") + streamEnd += streamStart + reader, err := zlib.NewReader(bytes.NewReader(compressed[streamStart:streamEnd])) + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(reader) + if closeErr := reader.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(content, []byte("Repeated content for compression")) { + t.Fatal("decompressed stream does not contain page content") + } +} + +func TestPDFDocumentEmbedsRegisteredTrueTypeFont(t *testing.T) { + ClearRegisteredFonts() + t.Cleanup(ClearRegisteredFonts) + RegisterFont("Go Regular", goregular.TTF) + document := NewPDFDocument() + document.AddPage(300, 400).AddText("Hello, Ω", 20, 350, 12, PDFColorBlack, false) + + pdf := document.Bytes() + + for _, marker := range [][]byte{ + []byte("/Subtype /Type0"), + []byte("/Subtype /CIDFontType2"), + []byte("/FontFile2"), + []byte("/ToUnicode"), + []byte("<03A9>"), + } { + if !bytes.Contains(pdf, marker) { + t.Errorf("PDF does not contain %q", marker) + } + } +} + func TestPDFDocumentWritesValidEnvelope(t *testing.T) { document := NewPDFDocument() page := document.AddPage(PageSizeA4.Width, PageSizeA4.Height) @@ -25,8 +91,12 @@ func TestPDFStreamLengthsAreExact(t *testing.T) { document := NewPDFDocument() document.AddPage(300, 400).AddText("Hello", 20, 350, 12, PDFColorBlack, false) pdf := document.Bytes() + assertPDFStreamLengths(t, pdf) +} - pattern := regexp.MustCompile(`/Length ([0-9]+) >>\nstream\n`) +func assertPDFStreamLengths(t *testing.T, pdf []byte) { + t.Helper() + pattern := regexp.MustCompile(`/Length ([0-9]+)(?: /Filter /FlateDecode)? >>\nstream\n`) matches := pattern.FindAllSubmatchIndex(pdf, -1) if len(matches) == 0 { t.Fatal("no PDF streams found") @@ -37,7 +107,7 @@ func TestPDFStreamLengthsAreExact(t *testing.T) { t.Fatal(err) } streamStart := match[1] - streamEndOffset := bytes.Index(pdf[streamStart:], []byte("endstream")) + streamEndOffset := bytes.Index(pdf[streamStart:], []byte("\nendstream")) if streamEndOffset < 0 { t.Fatal("stream terminator is missing") } diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go index edfd1e17..820ff807 100644 --- a/minipdf-go/xlsx.go +++ b/minipdf-go/xlsx.go @@ -22,7 +22,7 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { return nil, errorsNewMissingWorksheets() } pages := make([]textPage, 0, len(worksheets)) - for index, name := range worksheets { + for _, name := range worksheets { worksheetXML, readErr := files.read(name) if readErr != nil { return nil, readErr @@ -31,12 +31,65 @@ func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { if parseErr != nil { return nil, fmt.Errorf("parse %s: %w", name, parseErr) } - lines = append([]string{fmt.Sprintf("Sheet %d", index+1)}, lines...) - pages = append(pages, textPage{lines: lines, size: pageSize}) + lines = limitWorksheet(lines, options.MaxRows, options.MaxColumns) + if options.Landscape != nil { + isLandscape := pageSize.Width > pageSize.Height + if *options.Landscape != isLandscape { + pageSize.Width, pageSize.Height = pageSize.Height, pageSize.Width + } + } + for _, group := range splitWorksheetColumnGroups(lines, 9) { + group = append([]string{""}, group...) + pages = append(pages, textPage{lines: group, size: pageSize}) + } } return renderTextPages(pages, options), nil } +func limitWorksheet(lines []string, maxRows, maxColumns int) []string { + if maxRows > 0 && len(lines) > maxRows { + lines = lines[:maxRows] + } + if maxColumns <= 0 { + return lines + } + limited := make([]string, len(lines)) + for index, line := range lines { + cells := strings.Split(line, "\t") + if len(cells) > maxColumns { + cells = cells[:maxColumns] + } + limited[index] = strings.Join(cells, "\t") + } + return limited +} + +func splitWorksheetColumnGroups(lines []string, columnsPerPage int) [][]string { + maximumColumns := 0 + for _, line := range lines { + maximumColumns = max(maximumColumns, len(strings.Split(line, "\t"))) + } + if maximumColumns <= columnsPerPage || columnsPerPage <= 0 { + return [][]string{lines} + } + + groups := make([][]string, 0, (maximumColumns+columnsPerPage-1)/columnsPerPage) + for start := 0; start < maximumColumns; start += columnsPerPage { + end := min(start+columnsPerPage, maximumColumns) + group := make([]string, len(lines)) + for rowIndex, line := range lines { + cells := strings.Split(line, "\t") + if start >= len(cells) { + continue + } + rowEnd := min(end, len(cells)) + group[rowIndex] = strings.Join(cells[start:rowEnd], "\t") + } + groups = append(groups, group) + } + return groups +} + func errorsNewMissingWorksheets() error { return fmt.Errorf("Office package part %q is missing", "xl/worksheets/sheet1.xml") }