diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 00000000..9478cfe0 --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,64 @@ +name: Go CI + +on: + push: + branches: [main, master] + paths: + - 'minipdf-go/**' + - '.github/workflows/go-ci.yml' + - '.github/workflows/go-release.yml' + pull_request: + branches: [main, master] + paths: + - 'minipdf-go/**' + - '.github/workflows/go-ci.yml' + - '.github/workflows/go-release.yml' + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + defaults: + run: + working-directory: minipdf-go + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: '1.22.x' + cache: false + + - name: Check formatting + shell: bash + run: test -z "$(gofmt -l .)" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + windows-test: + runs-on: windows-latest + defaults: + run: + working-directory: minipdf-go + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: '1.22.x' + cache: false + + - name: Test + run: go test ./... \ No newline at end of file diff --git a/.github/workflows/go-release.yml b/.github/workflows/go-release.yml new file mode 100644 index 00000000..eb5ef871 --- /dev/null +++ b/.github/workflows/go-release.yml @@ -0,0 +1,124 @@ +name: Go Release + +on: + push: + tags: + - 'minipdf-go/v*' + workflow_dispatch: + inputs: + tag: + description: Existing Go module tag to release (for example, minipdf-go/v0.1.0) + required: true + type: string + +permissions: + contents: write + +concurrency: + group: go-release-${{ github.ref_name }}-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + defaults: + run: + working-directory: minipdf-go + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version: '1.22.x' + cache: false + + - name: Validate release tag + id: version + shell: bash + run: | + if [[ ! "$RELEASE_TAG" =~ ^minipdf-go/v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Tag '$RELEASE_TAG' must match minipdf-go/v." + exit 1 + fi + MODULE_PATH="$(go list -m -f '{{.Path}}')" + if [ "$MODULE_PATH" != "github.com/mini-software/MiniPdf/minipdf-go" ]; then + echo "::error::Unexpected Go module path '$MODULE_PATH'." + exit 1 + fi + VERSION="${RELEASE_TAG#minipdf-go/}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check formatting + shell: bash + run: test -z "$(gofmt -l .)" + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... + + - name: Build release archives + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mkdir -p dist + targets=( + windows/amd64 + windows/arm64 + linux/amd64 + linux/arm64 + darwin/amd64 + darwin/arm64 + ) + for target in "${targets[@]}"; do + IFS=/ read -r goos goarch <<< "$target" + archive="minipdf_${VERSION}_${goos}_${goarch}" + binary="minipdf" + if [ "$goos" = "windows" ]; then + binary="minipdf.exe" + fi + mkdir -p "dist/$archive" + CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build \ + -trimpath \ + -ldflags="-s -w -X main.version=$VERSION" \ + -o "dist/$archive/$binary" \ + ./cmd/minipdf + if [ "$goos" = "windows" ]; then + (cd "dist/$archive" && zip -q "../$archive.zip" "$binary") + else + tar -C "dist/$archive" -czf "dist/$archive.tar.gz" "$binary" + fi + rm -rf "dist/$archive" + done + (cd dist && sha256sum minipdf_* > checksums.txt) + + - name: Verify embedded version + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + actual="$(go run -ldflags="-X main.version=$VERSION" ./cmd/minipdf --version)" + if [ "$actual" != "minipdf $VERSION" ]; then + echo "::error::CLI reported '$actual'." + exit 1 + fi + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + name: MiniPdf Go ${{ steps.version.outputs.version }} + generate_release_notes: true + files: | + minipdf-go/dist/*.zip + minipdf-go/dist/*.tar.gz + minipdf-go/dist/checksums.txt \ No newline at end of file diff --git a/minipdf-go/README.md b/minipdf-go/README.md new file mode 100644 index 00000000..ecafa6cf --- /dev/null +++ b/minipdf-go/README.md @@ -0,0 +1,118 @@ +# MiniPdf for Go + +The experimental native Go implementation of MiniPdf. It provides a reusable +`minipdf` package and a dependency-free command-line application. + +[Project portal](../README.md) | [Rust implementation](../minipdf-rs/README.md) | +[.NET implementation](../documents/README.nuget.md) + +> The Go implementation is an early foundation and is not feature-equivalent +> with MiniPdf for .NET or Rust. It currently renders basic text from XLSX, +> DOCX, and PPTX packages. Complex Office layout is not yet supported. + +## Requirements + +- Go 1.22 or later +- No Microsoft Office, LibreOffice, Adobe Acrobat, or .NET runtime at runtime + +## Library + +```go +package main + +import minipdf "github.com/mini-software/MiniPdf/minipdf-go" + +func main() { + if err := minipdf.ConvertToPDF("report.docx", "report.pdf"); err != nil { + panic(err) + } +} +``` + +Convert in-memory Office package bytes: + +```go +pdf, err := minipdf.ConvertBytesToPDF(input) +``` + +Override the output page size: + +```go +size, err := minipdf.NewPageSize(400, 500) +if err != nil { + panic(err) +} +pdf, err := minipdf.ConvertBytesToPDFWithOptions(input, minipdf.ConversionOptions{ + PageSize: &size, +}) +``` + +## Command Line + +```powershell +go install github.com/mini-software/MiniPdf/minipdf-go/cmd/minipdf@latest +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 +``` + +## Current Scope + +| Capability | Go status | +|---|---| +| XLSX to PDF | Basic cell text and shared strings | +| DOCX to PDF | Basic paragraphs, tabs, line breaks, and explicit page breaks | +| 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 | + +The initial renderer deliberately does not claim support for Office styles, +images, tables, charts, themes, formulas, merged cells, or font embedding. + +## Development + +```powershell +cd minipdf-go +go fmt ./... +go vet ./... +go test ./... +go run ./cmd/minipdf path\to\input.docx +``` + +## Publishing + +Go modules are published from Git tags rather than uploaded to a package +registry. Because this module is in the `minipdf-go` repository subdirectory, +its tags must include that prefix. + +After the release change has been merged to `main`, create and push a semantic +version tag from the release commit: + +```powershell +git tag minipdf-go/v0.1.0 +git push origin minipdf-go/v0.1.0 +``` + +The [Go Release workflow](../.github/workflows/go-release.yml) validates the +module, builds Windows, Linux, and macOS CLI archives for AMD64 and ARM64, +writes SHA-256 checksums, and creates the GitHub Release. No package registry +token is required. The public Go module proxy discovers the library from the +same tag. + +Verify the published module: + +```powershell +go list -m github.com/mini-software/MiniPdf/minipdf-go@v0.1.0 +go install github.com/mini-software/MiniPdf/minipdf-go/cmd/minipdf@v0.1.0 +minipdf --version +``` + +The workflow can also be run manually for an existing tag. It intentionally +does not create tags, so a manual run cannot publish an untagged Go module. + +## License + +[Apache License 2.0](../LICENSE). \ No newline at end of file diff --git a/minipdf-go/cmd/minipdf/main.go b/minipdf-go/cmd/minipdf/main.go new file mode 100644 index 00000000..f6820bd8 --- /dev/null +++ b/minipdf-go/cmd/minipdf/main.go @@ -0,0 +1,174 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + minipdf "github.com/mini-software/MiniPdf/minipdf-go" +) + +var ( + errHelp = errors.New("help requested") + errVersion = errors.New("version requested") + version = "dev" +) + +type cliOptions struct { + input string + output string + paperSize string + pageWidth float64 + pageHeight float64 +} + +func main() { + options, err := parseArguments(os.Args[1:]) + if errors.Is(err, errHelp) { + printUsage() + return + } + if errors.Is(err, errVersion) { + fmt.Println("minipdf", version) + return + } + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } + if err := run(options); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} + +func run(options cliOptions) error { + if _, err := os.Stat(options.input); err != nil { + return fmt.Errorf("input file: %w", err) + } + extension := strings.ToLower(filepath.Ext(options.input)) + if extension != ".docx" && extension != ".xlsx" && extension != ".pptx" { + return fmt.Errorf("unsupported file type %q; supported: .xlsx, .docx, .pptx", extension) + } + + conversionOptions, err := conversionOptions(options) + if err != nil { + return err + } + output := options.output + if output == "" { + output = strings.TrimSuffix(options.input, filepath.Ext(options.input)) + ".pdf" + } + if err := minipdf.ConvertToPDFWithOptions(options.input, output, conversionOptions); err != nil { + return err + } + fmt.Println(output) + return nil +} + +func conversionOptions(options cliOptions) (minipdf.ConversionOptions, error) { + customPageSize := options.pageWidth != 0 || options.pageHeight != 0 + if options.paperSize != "" && customPageSize { + return minipdf.ConversionOptions{}, errors.New("use either --paper-size or --page-width/--page-height, not both") + } + if (options.pageWidth == 0) != (options.pageHeight == 0) { + return minipdf.ConversionOptions{}, errors.New("--page-width and --page-height must be specified together") + } + var pageSize *minipdf.PageSize + switch strings.ToLower(options.paperSize) { + case "": + case "a4": + size := minipdf.PageSizeA4 + pageSize = &size + case "letter": + size := minipdf.PageSizeLetter + pageSize = &size + default: + return minipdf.ConversionOptions{}, fmt.Errorf("unknown paper size %q; supported: a4, letter", options.paperSize) + } + if customPageSize { + size, err := minipdf.NewPageSize(options.pageWidth, options.pageHeight) + if err != nil { + return minipdf.ConversionOptions{}, err + } + pageSize = &size + } + return minipdf.ConversionOptions{PageSize: pageSize}, nil +} + +func parseArguments(arguments []string) (cliOptions, error) { + if len(arguments) > 0 && arguments[0] == "convert" { + arguments = arguments[1:] + } + var options cliOptions + for index := 0; index < len(arguments); index++ { + argument := arguments[index] + if argument == "-h" || argument == "--help" { + return cliOptions{}, errHelp + } + if argument == "--version" { + return cliOptions{}, errVersion + } + name, inlineValue, hasInlineValue := strings.Cut(argument, "=") + switch name { + case "-o", "--output", "--paper-size", "--page-width", "--page-height": + value := inlineValue + if !hasInlineValue { + index++ + if index >= len(arguments) { + return cliOptions{}, fmt.Errorf("%s requires a value", name) + } + value = arguments[index] + } + switch name { + case "-o", "--output": + options.output = value + case "--paper-size": + options.paperSize = value + case "--page-width": + width, err := strconv.ParseFloat(value, 64) + if err != nil { + return cliOptions{}, fmt.Errorf("invalid page width %q", value) + } + options.pageWidth = width + case "--page-height": + height, err := strconv.ParseFloat(value, 64) + if err != nil { + return cliOptions{}, fmt.Errorf("invalid page height %q", value) + } + options.pageHeight = height + } + default: + if strings.HasPrefix(argument, "-") { + return cliOptions{}, fmt.Errorf("unknown option %q", argument) + } + if options.input != "" { + return cliOptions{}, errors.New("only one input file can be converted at a time") + } + options.input = argument + } + } + if options.input == "" { + return cliOptions{}, errors.New("input file is required; use --help for usage") + } + return options, nil +} + +func printUsage() { + fmt.Println(`MiniPdf for Go + +Usage: + minipdf INPUT [options] + minipdf convert INPUT [options] + +Options: + -o, --output PATH Output PDF path + --paper-size SIZE a4 or letter + --page-width POINTS Custom page width + --page-height POINTS Custom page height + --version Show the build version + -h, --help Show this help`) +} diff --git a/minipdf-go/cmd/minipdf/main_test.go b/minipdf-go/cmd/minipdf/main_test.go new file mode 100644 index 00000000..ad9323fc --- /dev/null +++ b/minipdf-go/cmd/minipdf/main_test.go @@ -0,0 +1,20 @@ +package main + +import ( + "errors" + "testing" +) + +func TestParseArgumentsAcceptsVersionWithoutInput(t *testing.T) { + _, err := parseArguments([]string{"--version"}) + if !errors.Is(err, errVersion) { + t.Fatalf("parseArguments() error = %v, want errVersion", err) + } +} + +func TestConversionOptionsRejectsMixedPageSizes(t *testing.T) { + _, err := conversionOptions(cliOptions{paperSize: "a4", pageWidth: 300, pageHeight: 400}) + if err == nil { + t.Fatal("conversionOptions() accepted preset and custom page sizes") + } +} diff --git a/minipdf-go/docx.go b/minipdf-go/docx.go new file mode 100644 index 00000000..27b455e5 --- /dev/null +++ b/minipdf-go/docx.go @@ -0,0 +1,95 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +func convertDOCX(input []byte, options ConversionOptions) ([]byte, error) { + files, err := openOfficePackage(input) + if err != nil { + return nil, err + } + documentXML, err := files.read("word/document.xml") + if err != nil { + return nil, err + } + pages, pageSize, err := extractDOCX(documentXML) + if err != nil { + return nil, fmt.Errorf("parse word/document.xml: %w", err) + } + textPages := make([]textPage, len(pages)) + for index, lines := range pages { + textPages[index] = textPage{lines: lines, size: pageSize} + } + return renderTextPages(textPages, options), nil +} + +func extractDOCX(data []byte) ([][]string, PageSize, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + pageSize := PageSizeA4 + pages := [][]string{{}} + var paragraph strings.Builder + paragraphDepth := 0 + + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, PageSize{}, err + } + switch element := token.(type) { + case xml.StartElement: + switch element.Name.Local { + case "p": + paragraphDepth++ + case "t": + var text string + if err := decoder.DecodeElement(&text, &element); err != nil { + return nil, PageSize{}, err + } + paragraph.WriteString(text) + case "tab": + paragraph.WriteByte('\t') + case "br": + if attrValue(element, "type") == "page" { + appendDOCXParagraph(&pages[len(pages)-1], paragraph.String()) + paragraph.Reset() + pages = append(pages, []string{}) + } else { + paragraph.WriteByte('\n') + } + case "pgSz": + width, widthErr := strconv.ParseFloat(attrValue(element, "w"), 64) + height, heightErr := strconv.ParseFloat(attrValue(element, "h"), 64) + if widthErr == nil && heightErr == nil && width > 0 && height > 0 { + pageSize = PageSize{Width: width / 20, Height: height / 20} + } + } + case xml.EndElement: + if element.Name.Local == "p" && paragraphDepth > 0 { + paragraphDepth-- + if paragraphDepth == 0 { + appendDOCXParagraph(&pages[len(pages)-1], paragraph.String()) + paragraph.Reset() + } + } + } + } + if paragraph.Len() > 0 { + appendDOCXParagraph(&pages[len(pages)-1], paragraph.String()) + } + if len(pages) == 1 && len(pages[0]) == 0 { + pages[0] = append(pages[0], "Empty DOCX document") + } + return pages, pageSize, nil +} + +func appendDOCXParagraph(lines *[]string, paragraph string) { + *lines = append(*lines, strings.Split(paragraph, "\n")...) +} diff --git a/minipdf-go/go.mod b/minipdf-go/go.mod new file mode 100644 index 00000000..810a694c --- /dev/null +++ b/minipdf-go/go.mod @@ -0,0 +1,3 @@ +module github.com/mini-software/MiniPdf/minipdf-go + +go 1.22 \ No newline at end of file diff --git a/minipdf-go/minipdf.go b/minipdf-go/minipdf.go new file mode 100644 index 00000000..3b26352d --- /dev/null +++ b/minipdf-go/minipdf.go @@ -0,0 +1,158 @@ +package minipdf + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + ErrUnsupportedFormat = errors.New("unsupported or unknown Office document format") + PageSizeA4 = PageSize{Width: 595.28, Height: 841.89} + PageSizeLetter = PageSize{Width: 612, Height: 792} +) + +type OfficeFormat string + +const ( + OfficeFormatUnknown OfficeFormat = "unknown" + OfficeFormatXLSX OfficeFormat = "xlsx" + OfficeFormatDOCX OfficeFormat = "docx" + OfficeFormatPPTX OfficeFormat = "pptx" +) + +type PageSize struct { + Width float64 + Height float64 +} + +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{Width: width, Height: height}, nil +} + +type ConversionOptions struct { + PageSize *PageSize +} + +type RegisteredFont struct { + Name string + Data []byte +} + +var fontRegistry struct { + sync.RWMutex + fonts []RegisteredFont +} + +func RegisterFont(name string, data []byte) { + fontRegistry.Lock() + defer fontRegistry.Unlock() + fontRegistry.fonts = append(fontRegistry.fonts, RegisteredFont{Name: name, Data: bytes.Clone(data)}) +} + +func RegisteredFonts() []RegisteredFont { + fontRegistry.RLock() + defer fontRegistry.RUnlock() + fonts := make([]RegisteredFont, len(fontRegistry.fonts)) + for index, font := range fontRegistry.fonts { + fonts[index] = RegisteredFont{Name: font.Name, Data: bytes.Clone(font.Data)} + } + return fonts +} + +func DetectOfficeFormat(input []byte) (OfficeFormat, error) { + reader, err := zip.NewReader(bytes.NewReader(input), int64(len(input))) + if err != nil { + return OfficeFormatUnknown, fmt.Errorf("open Office package: %w", err) + } + for _, file := range reader.File { + name := strings.ReplaceAll(file.Name, `\`, "/") + switch { + case strings.HasPrefix(name, "word/"): + return OfficeFormatDOCX, nil + case strings.HasPrefix(name, "xl/"): + return OfficeFormatXLSX, nil + case strings.HasPrefix(name, "ppt/"): + return OfficeFormatPPTX, nil + } + } + return OfficeFormatUnknown, nil +} + +func ConvertToPDF(inputPath, outputPath string) error { + return ConvertToPDFWithOptions(inputPath, outputPath, ConversionOptions{}) +} + +func ConvertToPDFWithOptions(inputPath, outputPath string, options ConversionOptions) error { + pdf, err := ConvertToPDFBytesWithOptions(inputPath, options) + if err != nil { + return err + } + if err := os.WriteFile(outputPath, pdf, 0o644); err != nil { + return fmt.Errorf("write PDF: %w", err) + } + return nil +} + +func ConvertToPDFBytes(inputPath string) ([]byte, error) { + return ConvertToPDFBytesWithOptions(inputPath, ConversionOptions{}) +} + +func ConvertToPDFBytesWithOptions(inputPath string, options ConversionOptions) ([]byte, error) { + input, err := os.ReadFile(inputPath) + if err != nil { + return nil, fmt.Errorf("read input: %w", err) + } + extension := strings.ToLower(filepath.Ext(inputPath)) + format := OfficeFormatUnknown + switch extension { + case ".docx": + format = OfficeFormatDOCX + case ".xlsx": + format = OfficeFormatXLSX + case ".pptx": + format = OfficeFormatPPTX + } + return convertBytesAs(input, format, options) +} + +func ConvertBytesToPDF(input []byte) ([]byte, error) { + return ConvertBytesToPDFWithOptions(input, ConversionOptions{}) +} + +func ConvertBytesToPDFWithOptions(input []byte, options ConversionOptions) ([]byte, error) { + format, err := DetectOfficeFormat(input) + if err != nil { + return nil, err + } + return convertBytesAs(input, format, options) +} + +func convertBytesAs(input []byte, format OfficeFormat, options ConversionOptions) ([]byte, error) { + if format == OfficeFormatUnknown { + detected, err := DetectOfficeFormat(input) + if err != nil { + return nil, err + } + format = detected + } + switch format { + case OfficeFormatDOCX: + return convertDOCX(input, options) + case OfficeFormatXLSX: + return convertXLSX(input, options) + case OfficeFormatPPTX: + return convertPPTX(input, options) + default: + return nil, ErrUnsupportedFormat + } +} diff --git a/minipdf-go/minipdf_test.go b/minipdf-go/minipdf_test.go new file mode 100644 index 00000000..3943edb9 --- /dev/null +++ b/minipdf-go/minipdf_test.go @@ -0,0 +1,66 @@ +package minipdf + +import ( + "archive/zip" + "bytes" + "errors" + "math" + "testing" +) + +func TestDetectOfficeFormat(t *testing.T) { + tests := []struct { + name string + entry string + format OfficeFormat + }{ + {name: "docx", entry: "word/document.xml", format: OfficeFormatDOCX}, + {name: "xlsx", entry: "xl/workbook.xml", format: OfficeFormatXLSX}, + {name: "pptx", entry: "ppt/presentation.xml", format: OfficeFormatPPTX}, + {name: "unknown", entry: "custom/data.xml", format: OfficeFormatUnknown}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + format, err := DetectOfficeFormat(zipPackage(t, test.entry, "")) + if err != nil { + t.Fatalf("DetectOfficeFormat() error = %v", err) + } + if format != test.format { + t.Fatalf("DetectOfficeFormat() = %q, want %q", format, test.format) + } + }) + } +} + +func TestNewPageSizeRejectsInvalidDimensions(t *testing.T) { + for _, dimensions := range [][2]float64{{0, 100}, {-1, 100}, {100, math.Inf(1)}} { + if _, err := NewPageSize(dimensions[0], dimensions[1]); err == nil { + t.Fatalf("NewPageSize(%v, %v) succeeded", dimensions[0], dimensions[1]) + } + } +} + +func TestUnknownPackageIsUnsupported(t *testing.T) { + _, err := ConvertBytesToPDF(zipPackage(t, "custom/data.xml", "")) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("ConvertBytesToPDF() error = %v, want ErrUnsupportedFormat", err) + } +} + +func zipPackage(t *testing.T, name, content string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + file, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/minipdf-go/office.go b/minipdf-go/office.go new file mode 100644 index 00000000..a6eff8ed --- /dev/null +++ b/minipdf-go/office.go @@ -0,0 +1,154 @@ +package minipdf + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "path" + "sort" + "strconv" + "strings" + "unicode" +) + +type officePackage map[string]*zip.File + +type textPage struct { + lines []string + size PageSize +} + +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) + } + files := make(officePackage, len(reader.File)) + for _, file := range reader.File { + files[strings.ReplaceAll(file.Name, `\`, "/")] = file + } + return files, nil +} + +func (files officePackage) read(name string) ([]byte, error) { + file, ok := files[name] + if !ok { + return nil, fmt.Errorf("Office package part %q is missing", name) + } + reader, err := file.Open() + if err != nil { + return nil, fmt.Errorf("open Office package part %q: %w", name, err) + } + defer reader.Close() + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("read Office package part %q: %w", name, err) + } + return data, nil +} + +func renderTextPages(pages []textPage, options ConversionOptions) []byte { + document := NewPDFDocument() + for _, sourcePage := range pages { + pageSize := sourcePage.size + if options.PageSize != nil { + pageSize = *options.PageSize + } + addTextPages(document, sourcePage.lines, pageSize) + } + return document.Bytes() +} + +func addTextPages(document *PDFDocument, lines []string, pageSize PageSize) { + const ( + margin = 54.0 + fontSize = 11.0 + leading = 15.0 + ) + maxCharacters := int((pageSize.Width - margin*2) / (fontSize * 0.52)) + if maxCharacters < 10 { + maxCharacters = 10 + } + wrapped := make([]string, 0, len(lines)) + for _, line := range lines { + wrapped = append(wrapped, wrapText(line, maxCharacters)...) + } + if len(wrapped) == 0 { + wrapped = append(wrapped, "") + } + + page := document.AddPage(pageSize.Width, pageSize.Height) + y := pageSize.Height - margin + for _, line := range wrapped { + if y < margin { + page = document.AddPage(pageSize.Width, pageSize.Height) + y = pageSize.Height - margin + } + page.AddText(line, margin, y, fontSize, PDFColorBlack, false) + y -= leading + } +} + +func wrapText(text string, limit int) []string { + if text == "" { + return []string{""} + } + var lines []string + for _, explicitLine := range strings.Split(text, "\n") { + runes := []rune(explicitLine) + for len(runes) > limit { + breakAt := limit + for index := limit; index > 0; index-- { + if unicode.IsSpace(runes[index-1]) { + breakAt = index + break + } + } + lines = append(lines, strings.TrimSpace(string(runes[:breakAt]))) + runes = []rune(strings.TrimLeftFunc(string(runes[breakAt:]), unicode.IsSpace)) + } + lines = append(lines, string(runes)) + } + return lines +} + +func attrValue(start xml.StartElement, localName string) string { + for _, attribute := range start.Attr { + if attribute.Name.Local == localName { + return attribute.Value + } + } + return "" +} + +func sortedPackageParts(files officePackage, prefix, extension string) []string { + var names []string + for name := range files { + if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, extension) && !strings.Contains(name, "/_rels/") { + names = append(names, name) + } + } + sort.Slice(names, func(left, right int) bool { + leftNumber := trailingNumber(strings.TrimSuffix(path.Base(names[left]), extension)) + rightNumber := trailingNumber(strings.TrimSuffix(path.Base(names[right]), extension)) + if leftNumber == rightNumber { + return names[left] < names[right] + } + return leftNumber < rightNumber + }) + return names +} + +func trailingNumber(value string) int { + start := len(value) + for start > 0 && value[start-1] >= '0' && value[start-1] <= '9' { + start-- + } + number, err := strconv.Atoi(value[start:]) + if err != nil { + return 0 + } + return number +} diff --git a/minipdf-go/office_test.go b/minipdf-go/office_test.go new file mode 100644 index 00000000..a5b46a80 --- /dev/null +++ b/minipdf-go/office_test.go @@ -0,0 +1,76 @@ +package minipdf + +import ( + "archive/zip" + "bytes" + "testing" +) + +func TestConvertDOCXToPDF(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "word/document.xml": `Hello DOCX`, + }) + + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "Hello DOCX", "/MediaBox [0 0 612 792]") +} + +func TestConvertXLSXToPDF(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "xl/workbook.xml": ``, + "xl/sharedStrings.xml": `Hello XLSX`, + "xl/worksheets/sheet1.xml": `0Cell B` + + ``, + }) + + pdf, err := ConvertBytesToPDF(input) + assertPDFContains(t, pdf, err, "Hello XLSX", "Cell B", "/MediaBox [0 0 792 612]") +} + +func TestConvertPPTXToPDF(t *testing.T) { + input := officePackageBytes(t, map[string]string{ + "ppt/presentation.xml": ``, + "ppt/slides/slide1.xml": `Hello PPTX`, + }) + + customSize, err := NewPageSize(300, 400) + if err != nil { + t.Fatal(err) + } + pdf, err := ConvertBytesToPDFWithOptions(input, ConversionOptions{PageSize: &customSize}) + assertPDFContains(t, pdf, err, "Hello PPTX", "/MediaBox [0 0 300 400]") +} + +func assertPDFContains(t *testing.T, pdf []byte, err error, values ...string) { + t.Helper() + if err != nil { + t.Fatalf("conversion error = %v", err) + } + if !bytes.HasPrefix(pdf, []byte("%PDF-1.4")) { + t.Fatal("conversion did not return a PDF") + } + for _, value := range values { + if !bytes.Contains(pdf, []byte(value)) { + t.Errorf("PDF does not contain %q", value) + } + } +} + +func officePackageBytes(t *testing.T, entries map[string]string) []byte { + t.Helper() + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for name, content := range entries { + file, err := writer.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/minipdf-go/pdf.go b/minipdf-go/pdf.go new file mode 100644 index 00000000..e67694db --- /dev/null +++ b/minipdf-go/pdf.go @@ -0,0 +1,198 @@ +package minipdf + +import ( + "bytes" + "fmt" + "strconv" + "strings" + "unicode" +) + +type PDFColor struct { + Red float64 + Green float64 + Blue float64 +} + +var ( + PDFColorBlack = PDFColor{} + PDFColorWhite = PDFColor{Red: 1, Green: 1, Blue: 1} + PDFColorLightGray = PDFColor{Red: 0.92, Green: 0.92, Blue: 0.92} + PDFColorTableHeader = PDFColor{Red: 0.86, Green: 0.91, Blue: 0.96} +) + +type pdfOperation interface { + appendPDF(*bytes.Buffer) +} + +type PDFDocument struct { + pages []*PDFPage +} + +type PDFPage struct { + Width float64 + Height float64 + operations []pdfOperation +} + +func NewPDFDocument() *PDFDocument { + return &PDFDocument{} +} + +func (document *PDFDocument) AddPage(width, height float64) *PDFPage { + page := &PDFPage{Width: width, Height: height} + document.pages = append(document.pages, page) + return page +} + +func (page *PDFPage) AddText(text string, x, y, fontSize float64, color PDFColor, bold bool) { + page.operations = append(page.operations, textOperation{ + text: text, x: x, y: y, fontSize: fontSize, color: color, bold: bold, + }) +} + +func (page *PDFPage) AddRect(x, y, width, height float64, color PDFColor) { + page.operations = append(page.operations, rectOperation{ + x: x, y: y, width: width, height: height, color: color, + }) +} + +func (page *PDFPage) AddLine(x1, y1, x2, y2 float64, color PDFColor, width float64) { + page.operations = append(page.operations, lineOperation{ + x1: x1, y1: y1, x2: x2, y2: y2, color: color, width: width, + }) +} + +type textOperation struct { + text string + x, y, fontSize float64 + color PDFColor + bold bool +} + +func (operation textOperation) appendPDF(buffer *bytes.Buffer) { + font := "F1" + if operation.bold { + font = "F2" + } + fmt.Fprintf(buffer, "BT /%s %s Tf %s %s %s rg %s %s Td (%s) Tj ET\n", + font, + pdfNumber(operation.fontSize), + pdfNumber(operation.color.Red), + pdfNumber(operation.color.Green), + pdfNumber(operation.color.Blue), + pdfNumber(operation.x), + pdfNumber(operation.y), + escapePDFText(operation.text), + ) +} + +type rectOperation struct { + x, y, width, height float64 + color PDFColor +} + +func (operation rectOperation) appendPDF(buffer *bytes.Buffer) { + fmt.Fprintf(buffer, "%s %s %s rg %s %s %s %s re f\n", + pdfNumber(operation.color.Red), + pdfNumber(operation.color.Green), + pdfNumber(operation.color.Blue), + pdfNumber(operation.x), + pdfNumber(operation.y), + pdfNumber(operation.width), + pdfNumber(operation.height), + ) +} + +type lineOperation struct { + x1, y1, x2, y2 float64 + color PDFColor + width float64 +} + +func (operation lineOperation) appendPDF(buffer *bytes.Buffer) { + 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), + pdfNumber(operation.color.Blue), + pdfNumber(operation.width), + pdfNumber(operation.x1), + pdfNumber(operation.y1), + pdfNumber(operation.x2), + pdfNumber(operation.y2), + ) +} + +func (document *PDFDocument) Bytes() []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[0] = []byte("<< /Type /Catalog /Pages 2 0 R >>") + + pageReferences := make([]string, pageCount) + for index := range pages { + pageReferences[index] = fmt.Sprintf("%d 0 R", 5+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 + 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, + )) + var content bytes.Buffer + for _, operation := range page.operations { + operation.appendPDF(&content) + } + objects[contentObjectNumber-1] = []byte(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", content.Len(), content.String())) + } + + var output bytes.Buffer + output.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets := make([]int, len(objects)+1) + for index, object := range objects { + offsets[index+1] = output.Len() + fmt.Fprintf(&output, "%d 0 obj\n", index+1) + output.Write(object) + output.WriteString("\nendobj\n") + } + xrefOffset := output.Len() + fmt.Fprintf(&output, "xref\n0 %d\n0000000000 65535 f \n", len(objects)+1) + for index := 1; index < len(offsets); index++ { + fmt.Fprintf(&output, "%010d 00000 n \n", offsets[index]) + } + fmt.Fprintf(&output, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xrefOffset) + return output.Bytes() +} + +func pdfNumber(value float64) string { + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func escapePDFText(text string) string { + var escaped strings.Builder + for _, character := range text { + switch character { + case '\\', '(', ')': + escaped.WriteByte('\\') + escaped.WriteRune(character) + case '\n', '\r', '\t': + escaped.WriteByte(' ') + default: + if character <= 0xff && !unicode.IsControl(character) { + escaped.WriteRune(character) + } else { + escaped.WriteByte('?') + } + } + } + return escaped.String() +} diff --git a/minipdf-go/pdf_test.go b/minipdf-go/pdf_test.go new file mode 100644 index 00000000..a8167551 --- /dev/null +++ b/minipdf-go/pdf_test.go @@ -0,0 +1,49 @@ +package minipdf + +import ( + "bytes" + "regexp" + "strconv" + "testing" +) + +func TestPDFDocumentWritesValidEnvelope(t *testing.T) { + document := NewPDFDocument() + page := document.AddPage(PageSizeA4.Width, PageSizeA4.Height) + page.AddText("Hello from Go MiniPdf", 72, 760, 14, PDFColorBlack, false) + + pdf := document.Bytes() + if !bytes.HasPrefix(pdf, []byte("%PDF-1.4")) { + t.Fatal("PDF header is missing") + } + if !bytes.HasSuffix(pdf, []byte("%%EOF\n")) { + t.Fatal("PDF EOF marker is missing") + } +} + +func TestPDFStreamLengthsAreExact(t *testing.T) { + document := NewPDFDocument() + document.AddPage(300, 400).AddText("Hello", 20, 350, 12, PDFColorBlack, false) + pdf := document.Bytes() + + pattern := regexp.MustCompile(`/Length ([0-9]+) >>\nstream\n`) + matches := pattern.FindAllSubmatchIndex(pdf, -1) + if len(matches) == 0 { + t.Fatal("no PDF streams found") + } + for _, match := range matches { + declared, err := strconv.Atoi(string(pdf[match[2]:match[3]])) + if err != nil { + t.Fatal(err) + } + streamStart := match[1] + streamEndOffset := bytes.Index(pdf[streamStart:], []byte("endstream")) + if streamEndOffset < 0 { + t.Fatal("stream terminator is missing") + } + actual := streamEndOffset + if declared != actual { + t.Fatalf("stream length = %d, want %d", declared, actual) + } + } +} diff --git a/minipdf-go/pptx.go b/minipdf-go/pptx.go new file mode 100644 index 00000000..65de5f9a --- /dev/null +++ b/minipdf-go/pptx.go @@ -0,0 +1,110 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +func convertPPTX(input []byte, options ConversionOptions) ([]byte, error) { + files, err := openOfficePackage(input) + if err != nil { + return nil, err + } + presentationXML, err := files.read("ppt/presentation.xml") + if err != nil { + return nil, err + } + pageSize, err := extractSlideSize(presentationXML) + if err != nil { + return nil, fmt.Errorf("parse ppt/presentation.xml: %w", err) + } + slides := sortedPackageParts(files, "ppt/slides/", ".xml") + if len(slides) == 0 { + return nil, fmt.Errorf("Office package part %q is missing", "ppt/slides/slide1.xml") + } + pages := make([]textPage, 0, len(slides)) + for _, name := range slides { + slideXML, readErr := files.read(name) + if readErr != nil { + return nil, readErr + } + lines, parseErr := extractSlideText(slideXML) + if parseErr != nil { + return nil, fmt.Errorf("parse %s: %w", name, parseErr) + } + pages = append(pages, textPage{lines: lines, size: pageSize}) + } + return renderTextPages(pages, options), nil +} + +func extractSlideSize(data []byte) (PageSize, error) { + pageSize := PageSize{Width: 720, Height: 540} + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + return pageSize, nil + } + return PageSize{}, err + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "sldSz" { + continue + } + width, widthErr := strconv.ParseFloat(attrValue(start, "cx"), 64) + height, heightErr := strconv.ParseFloat(attrValue(start, "cy"), 64) + if widthErr == nil && heightErr == nil && width > 0 && height > 0 { + pageSize = PageSize{Width: width / 12700, Height: height / 12700} + } + return pageSize, nil + } +} + +func extractSlideText(data []byte) ([]string, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + var lines []string + var paragraph strings.Builder + paragraphDepth := 0 + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, err + } + switch element := token.(type) { + case xml.StartElement: + switch element.Name.Local { + case "p": + paragraphDepth++ + case "t": + var text string + if err := decoder.DecodeElement(&text, &element); err != nil { + return nil, err + } + paragraph.WriteString(text) + case "br": + paragraph.WriteByte('\n') + case "tab": + paragraph.WriteString(" ") + } + case xml.EndElement: + if element.Name.Local == "p" && paragraphDepth > 0 { + paragraphDepth-- + if paragraphDepth == 0 { + lines = append(lines, strings.Split(paragraph.String(), "\n")...) + paragraph.Reset() + } + } + } + } + if len(lines) == 0 { + lines = append(lines, "Empty PPTX slide") + } + return lines, nil +} diff --git a/minipdf-go/xlsx.go b/minipdf-go/xlsx.go new file mode 100644 index 00000000..edfd1e17 --- /dev/null +++ b/minipdf-go/xlsx.go @@ -0,0 +1,158 @@ +package minipdf + +import ( + "bytes" + "encoding/xml" + "fmt" + "strconv" + "strings" +) + +func convertXLSX(input []byte, options ConversionOptions) ([]byte, error) { + files, err := openOfficePackage(input) + if err != nil { + return nil, err + } + sharedStrings, err := readSharedStrings(files) + if err != nil { + return nil, err + } + worksheets := sortedPackageParts(files, "xl/worksheets/", ".xml") + if len(worksheets) == 0 { + return nil, errorsNewMissingWorksheets() + } + pages := make([]textPage, 0, len(worksheets)) + for index, name := range worksheets { + worksheetXML, readErr := files.read(name) + if readErr != nil { + return nil, readErr + } + lines, pageSize, parseErr := extractWorksheet(worksheetXML, sharedStrings) + 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}) + } + return renderTextPages(pages, options), nil +} + +func errorsNewMissingWorksheets() error { + return fmt.Errorf("Office package part %q is missing", "xl/worksheets/sheet1.xml") +} + +func readSharedStrings(files officePackage) ([]string, error) { + if _, ok := files["xl/sharedStrings.xml"]; !ok { + return nil, nil + } + data, err := files.read("xl/sharedStrings.xml") + if err != nil { + return nil, err + } + decoder := xml.NewDecoder(bytes.NewReader(data)) + var values []string + var current strings.Builder + inString := false + for { + token, tokenErr := decoder.Token() + if tokenErr != nil { + if tokenErr.Error() == "EOF" { + break + } + return nil, fmt.Errorf("parse xl/sharedStrings.xml: %w", tokenErr) + } + switch element := token.(type) { + case xml.StartElement: + if element.Name.Local == "si" { + inString = true + current.Reset() + } else if element.Name.Local == "t" && inString { + var text string + if err := decoder.DecodeElement(&text, &element); err != nil { + return nil, err + } + current.WriteString(text) + } + case xml.EndElement: + if element.Name.Local == "si" && inString { + values = append(values, current.String()) + inString = false + } + } + } + return values, nil +} + +func extractWorksheet(data []byte, sharedStrings []string) ([]string, PageSize, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + pageSize := PageSizeA4 + var lines []string + var row []string + for { + token, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, PageSize{}, err + } + switch element := token.(type) { + case xml.StartElement: + switch element.Name.Local { + case "row": + row = nil + case "c": + value, cellErr := decodeWorksheetCell(decoder, element, sharedStrings) + if cellErr != nil { + return nil, PageSize{}, cellErr + } + row = append(row, value) + case "pageSetup": + if attrValue(element, "paperSize") == "1" { + pageSize = PageSizeLetter + } + if attrValue(element, "orientation") == "landscape" { + pageSize.Width, pageSize.Height = pageSize.Height, pageSize.Width + } + } + case xml.EndElement: + if element.Name.Local == "row" { + lines = append(lines, strings.Join(row, "\t")) + } + } + } + return lines, pageSize, nil +} + +func decodeWorksheetCell(decoder *xml.Decoder, start xml.StartElement, sharedStrings []string) (string, error) { + cellType := attrValue(start, "t") + var value strings.Builder + depth := 1 + for depth > 0 { + token, err := decoder.Token() + if err != nil { + return "", err + } + switch element := token.(type) { + case xml.StartElement: + depth++ + if element.Name.Local == "v" || element.Name.Local == "t" { + var text string + if err := decoder.DecodeElement(&text, &element); err != nil { + return "", err + } + depth-- + value.WriteString(text) + } + case xml.EndElement: + depth-- + } + } + if cellType == "s" { + index, err := strconv.Atoi(strings.TrimSpace(value.String())) + if err == nil && index >= 0 && index < len(sharedStrings) { + return sharedStrings[index], nil + } + } + return value.String(), nil +}