# Directory Structure ``` ├── .devcontainer │ ├── devcontainer.json │ ├── Dockerfile │ ├── init-firewall.sh │ └── postCreateCommand.sh ├── .dockerignore ├── .editorconfig ├── .github │ └── workflows │ └── publish.yml ├── .gitignore ├── .goreleaser.yaml ├── .npmignore ├── CLAUDE.md ├── cmd │ └── excel-mcp-server │ └── main.go ├── Dockerfile ├── docs │ ├── design │ │ └── excel-style-schema.md │ └── img │ ├── icon-400.png │ └── icon-800.png ├── go.mod ├── go.sum ├── internal │ ├── excel │ │ ├── excel_excelize.go │ │ ├── excel_ole.go │ │ ├── excel.go │ │ ├── pagination.go │ │ └── util.go │ ├── mcp │ │ └── error.go │ ├── server │ │ └── server.go │ └── tools │ ├── common.go │ ├── config.go │ ├── excel_copy_sheet.go │ ├── excel_create_table.go │ ├── excel_describe_sheets.go │ ├── excel_format_range.go │ ├── excel_read_sheet.go │ ├── excel_screen_capture.go │ └── excel_write_to_sheet.go ├── launcher │ └── launcher.ts ├── LICENSE ├── package-lock.json ├── package.json ├── projectBrief.md ├── README.md ├── smithery.yaml └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` /dist /node_modules ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` /.github /cmd /internal /go.mod /go.sum /.goreleaser.yaml ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` /node_modules /dist /memory-bank # Added by goreleaser init: dist/ # RooFlow .roo .roomodes .clinerules-default # repomix repomix-output.xml # Claude Code CLAUDE.local.md .claude/settings.local.json ``` -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- ``` # EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false indent_size = 4 ``` -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- ```yaml version: 2 before: hooks: # You may remove this if you don't use go modules. - go mod tidy # you may remove this if you don't need go generate - go generate ./... builds: - env: - CGO_ENABLED=0 main: ./cmd/excel-mcp-server/main.go goos: - linux - windows - darwin archives: - formats: ['tar.gz'] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} # use zip for windows archives format_overrides: - goos: windows formats: ['zip'] builds_info: mode: 0755 changelog: sort: asc filters: exclude: - "^docs:" - "^test:" ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Excel MCP Server <img src="https://github.com/negokaz/excel-mcp-server/blob/main/docs/img/icon-800.png?raw=true" width="128"> <a href="https://glama.ai/mcp/servers/@negokaz/excel-mcp-server"> <img width="380" height="200" src="https://glama.ai/mcp/servers/@negokaz/excel-mcp-server/badge" alt="Excel Server MCP server" /> </a> [](https://www.npmjs.com/package/@negokaz/excel-mcp-server) [](https://smithery.ai/server/@negokaz/excel-mcp-server) A Model Context Protocol (MCP) server that reads and writes MS Excel data. ## Features - Read/Write text values - Read/Write formulas - Create new sheets **🪟Windows only:** - Live editing - Capture screen image from a sheet For more details, see the [tools](#tools) section. ## Requirements - Node.js 20.x or later ## Supported file formats - xlsx (Excel book) - xlsm (Excel macro-enabled book) - xltx (Excel template) - xltm (Excel macro-enabled template) ## Installation ### Installing via NPM excel-mcp-server is automatically installed by adding the following configuration to the MCP servers configuration. For Windows: ```json { "mcpServers": { "excel": { "command": "cmd", "args": ["/c", "npx", "--yes", "@negokaz/excel-mcp-server"], "env": { "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000" } } } } ``` For other platforms: ```json { "mcpServers": { "excel": { "command": "npx", "args": ["--yes", "@negokaz/excel-mcp-server"], "env": { "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000" } } } } ``` ### Installing via Smithery To install Excel MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@negokaz/excel-mcp-server): ```bash npx -y @smithery/cli install @negokaz/excel-mcp-server --client claude ``` <h2 id="tools">Tools</h2> ### `excel_describe_sheets` List all sheet information of specified Excel file. **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file ### `excel_read_sheet` Read values from Excel sheet with pagination. **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `sheetName` - Sheet name in the Excel file - `range` - Range of cells to read in the Excel sheet (e.g., "A1:C10"). [default: first paging range] - `showFormula` - Show formula instead of value [default: false] - `showStyle` - Show style information for cells [default: false] ### `excel_screen_capture` **[Windows only]** Take a screenshot of the Excel sheet with pagination. **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `sheetName` - Sheet name in the Excel file - `range` - Range of cells to read in the Excel sheet (e.g., "A1:C10"). [default: first paging range] ### `excel_write_to_sheet` Write values to the Excel sheet. **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `sheetName` - Sheet name in the Excel file - `newSheet` - Create a new sheet if true, otherwise write to the existing sheet - `range` - Range of cells to read in the Excel sheet (e.g., "A1:C10"). - `values` - Values to write to the Excel sheet. If the value is a formula, it should start with "=" ### `excel_create_table` Create a table in the Excel sheet **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `sheetName` - Sheet name where the table is created - `range` - Range to be a table (e.g., "A1:C10") - `tableName` - Table name to be created ### `excel_copy_sheet` Copy existing sheet to a new sheet **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `srcSheetName` - Source sheet name in the Excel file - `dstSheetName` - Sheet name to be copied ### `excel_format_range` Format cells in the Excel sheet with style information **Arguments:** - `fileAbsolutePath` - Absolute path to the Excel file - `sheetName` - Sheet name in the Excel file - `range` - Range of cells in the Excel sheet (e.g., "A1:C3") - `styles` - 2D array of style objects for each cell. If a cell does not change style, use null. The number of items of the array must match the range size. - Style object properties: - `border`: Array of border styles (type, color, style) - `font`: Font styling (bold, italic, underline, size, strike, color, vertAlign) - `fill`: Fill/background styling (type, pattern, color, shading) - `numFmt`: Custom number format string - `decimalPlaces`: Number of decimal places (0-30) <h2 id="configuration">Configuration</h2> You can change the MCP Server behaviors by the following environment variables: ### `EXCEL_MCP_PAGING_CELLS_LIMIT` The maximum number of cells to read in a single paging operation. [default: 4000] ## License Copyright (c) 2025 Kazuki Negoro excel-mcp-server is released under the [MIT License](LICENSE) ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview This is an Excel MCP (Model Context Protocol) Server that enables AI assistants to interact with Microsoft Excel files. It provides a bridge between AI models and Excel spreadsheets for programmatic data manipulation. ## Development Commands ### Build and Development ```bash npm run build # Build Go binaries + compile TypeScript npm run watch # Watch TypeScript files for changes npm run debug # Debug with MCP inspector ``` ### Testing ```bash go test ./... # Run all Go tests go test ./internal/tools -v # Run specific package tests go test -run TestReadSheetData ./internal/tools # Run specific test ``` ### Linting and Formatting ```bash go fmt ./... # Format Go code go vet ./... # Vet Go code for issues ``` ## Architecture ### Core Components **Dual Backend Architecture**: The server supports two Excel backends: - **Windows**: OLE automation for live Excel interaction (`excel_ole.go`) - **Cross-platform**: Excelize library for file operations (`excel_excelize.go`) **Key Interfaces**: - `ExcelInterface` in `internal/excel/excel.go` - Unified Excel operations API - `Tool` interface in `internal/tools/` - MCP tool implementations **Entry Points**: - `cmd/excel-mcp-server/main.go` - Go binary entry point - `launcher/launcher.ts` - Cross-platform launcher that selects appropriate binary ### Tool System MCP tools are implemented in `internal/tools/`: - `excel_describe_sheets` - List worksheets and metadata - `excel_read_sheet` - Read sheet data with pagination - `excel_write_to_sheet` - Write data to sheets - `excel_create_table` - Create Excel tables - `excel_copy_sheet` - Copy sheets between workbooks - `excel_screen_capture` - Windows-only screenshot functionality ### Pagination System Large datasets are handled through configurable pagination: - Default limit: 4000 cells - Configurable via `EXCEL_MCP_PAGING_CELLS_LIMIT` environment variable - Implemented in `internal/excel/pagination.go` ## File Structure ``` cmd/excel-mcp-server/ # Main application entry point internal/ excel/ # Excel abstraction layer server/ # MCP server implementation tools/ # MCP tool implementations launcher/ # TypeScript launcher memory-bank/ # Development context and progress ``` ## Build System Uses GoReleaser (`.goreleaser.yaml`) to create cross-platform binaries: - Windows: amd64, 386, arm64 - macOS: amd64, arm64 - Linux: amd64, 386, arm64 TypeScript launcher is compiled to `dist/launcher.js` and published to NPM. ## Platform Differences **Windows-specific features**: - Live Excel interaction via OLE automation - Screen capture capabilities - Requires Excel to be installed **Cross-platform features**: - File-based Excel operations only - No live editing capabilities - Works with xlsx, xlsm, xltx, xltm formats ## Configuration Environment variables: - `EXCEL_MCP_PAGING_CELLS_LIMIT` - Maximum cells per page (default: 4000) ## Dependencies **Go**: Requires Go 1.23.0+ with Go 1.24.0 toolchain **Node.js**: Requires Node.js 20.x+ for TypeScript compilation **Key packages**: - `github.com/mark3labs/mcp-go` - MCP framework - `github.com/xuri/excelize/v2` - Excel file operations - `github.com/go-ole/go-ole` - Windows OLE automation ``` -------------------------------------------------------------------------------- /.devcontainer/postCreateCommand.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash set -ex npm install -g @anthropic-ai/claude-code npm install -g @goreleaser/goreleaser ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:20-slim AS release # Set the working directory WORKDIR /app RUN npm install -g @negokaz/[email protected] # Command to run the application ENTRYPOINT ["excel-mcp-server"] ``` -------------------------------------------------------------------------------- /cmd/excel-mcp-server/main.go: -------------------------------------------------------------------------------- ```go package main import ( "fmt" "os" "github.com/negokaz/excel-mcp-server/internal/server" ) var ( version = "dev" ) func main() { s := server.New(version) err := s.Start() if err != nil { fmt.Fprintf(os.Stderr, "Failed to start the server: %v\n", err) os.Exit(1) } } ``` -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- ```json { "name": "Claude Code Dev Container", "build": { "dockerfile": "Dockerfile" }, "features": { "ghcr.io/devcontainers/features/go:1":{} }, "mounts": [ "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind,consistency=cached" ], "postCreateCommand": ".devcontainer/postCreateCommand.sh", "remoteUser": "node" } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. {} commandFunction: # A function that produces the CLI command to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['dist/launcher.js'], env: {} }) ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", "rootDir": "./launcher", }, "include": ["launcher/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /internal/tools/config.go: -------------------------------------------------------------------------------- ```go package tools import ( z "github.com/Oudwins/zog" "github.com/Oudwins/zog/zenv" ) type EnvConfig struct { EXCEL_MCP_PAGING_CELLS_LIMIT int } var configSchema = z.Struct(z.Shape{ "EXCEL_MCP_PAGING_CELLS_LIMIT": z.Int().GT(0).Default(4000), }) func LoadConfig() (EnvConfig, z.ZogIssueMap) { config := EnvConfig{} issues := configSchema.Parse(zenv.NewDataProvider(), &config) return config, issues } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@negokaz/excel-mcp-server", "version": "0.12.0", "description": "An MCP server that reads and writes spreadsheet data to MS Excel file", "author": "negokaz", "license": "MIT", "bin": { "excel-mcp-server": "dist/launcher.js" }, "scripts": { "build": "goreleaser build --snapshot --clean && tsc", "watch": "tsc --watch", "debug": "npx @modelcontextprotocol/inspector dist/launcher.js" }, "devDependencies": { "@types/node": "^22.13.4", "typescript": "^5.7.3" }, "publishConfig": { "access": "public" } } ``` -------------------------------------------------------------------------------- /internal/mcp/error.go: -------------------------------------------------------------------------------- ```go package mcp import ( "fmt" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" ) func NewToolResultInvalidArgumentError(message string) *mcp.CallToolResult { return mcp.NewToolResultError(fmt.Sprintf("Invalid argument: %s", message)) } func NewToolResultZogIssueMap(errs z.ZogIssueMap) *mcp.CallToolResult { issues := z.Issues.SanitizeMap(errs) var issueResults []mcp.Content for k, messages := range issues { for _, message := range messages { issueResults = append(issueResults, mcp.NewTextContent(fmt.Sprintf("Invalid argument: %s: %s", k, message))) } } return &mcp.CallToolResult{ Content: issueResults, IsError: true, } } ``` -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- ```go package server import ( "runtime" "github.com/mark3labs/mcp-go/server" "github.com/negokaz/excel-mcp-server/internal/tools" ) type ExcelServer struct { server *server.MCPServer } func New(version string) *ExcelServer { s := &ExcelServer{} s.server = server.NewMCPServer( "excel-mcp-server", version, ) tools.AddExcelDescribeSheetsTool(s.server) tools.AddExcelReadSheetTool(s.server) if runtime.GOOS == "windows" { tools.AddExcelScreenCaptureTool(s.server) } tools.AddExcelWriteToSheetTool(s.server) tools.AddExcelCreateTableTool(s.server) tools.AddExcelCopySheetTool(s.server) tools.AddExcelFormatRangeTool(s.server) return s } func (s *ExcelServer) Start() error { return server.ServeStdio(s.server) } ``` -------------------------------------------------------------------------------- /launcher/launcher.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import * as path from 'path' import * as childProcess from 'child_process' const BINARY_DISTRIBUTION_PACKAGES: any = { win32_ia32: "excel-mcp-server_windows_386_sse2", win32_x64: "excel-mcp-server_windows_amd64_v1", win32_arm64: "excel-mcp-server_windows_arm64_v8.0", darwin_x64: "excel-mcp-server_darwin_amd64_v1", darwin_arm64: "excel-mcp-server_darwin_arm64_v8.0", linux_ia32: "excel-mcp-server_linux_386_sse2", linux_x64: "excel-mcp-server_linux_amd64_v1", linux_arm64: "excel-mcp-server_linux_arm64_v8.0", } function getBinaryPath(): string { const suffix = process.platform === 'win32' ? '.exe' : ''; const pkg = BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`]; if (pkg) { return path.resolve(__dirname, pkg, `excel-mcp-server${suffix}`); } else { throw new Error(`Unsupported platform: ${process.platform}_${process.arch}`); } } childProcess.execFileSync(getBinaryPath(), process.argv, { stdio: 'inherit', }); ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish Package to npmjs on: push: tags: - 'v*' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: go-version: stable - name: Install GoReleaser uses: goreleaser/goreleaser-action@v6 with: install-only: true distribution: goreleaser # 'latest', 'nightly', or a semver version: "~> v2" - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Publish to npmjs run: npm publish --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Releases run: goreleaser release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` -------------------------------------------------------------------------------- /internal/excel/util.go: -------------------------------------------------------------------------------- ```go package excel import ( "fmt" "os" "path" "regexp" "github.com/xuri/excelize/v2" ) // parseRange parses Excel's range string (e.g. A1:C10 or A1) func ParseRange(rangeStr string) (int, int, int, int, error) { re := regexp.MustCompile(`^(\$?[A-Z]+\$?\d+)(?::(\$?[A-Z]+\$?\d+))?$`) matches := re.FindStringSubmatch(rangeStr) if matches == nil { return 0, 0, 0, 0, fmt.Errorf("invalid range format: %s", rangeStr) } startCol, startRow, err := excelize.CellNameToCoordinates(matches[1]) if err != nil { return 0, 0, 0, 0, err } if matches[2] == "" { // Single cell case return startCol, startRow, startCol, startRow, nil } endCol, endRow, err := excelize.CellNameToCoordinates(matches[2]) if err != nil { return 0, 0, 0, 0, err } return startCol, startRow, endCol, endRow, nil } func NormalizeRange(rangeStr string) string { startCol, startRow, endCol, endRow, _ := ParseRange(rangeStr) startCell, _ := excelize.CoordinatesToCellName(startCol, startRow) endCell, _ := excelize.CoordinatesToCellName(endCol, endRow) return fmt.Sprintf("%s:%s", startCell, endCell) } // FileIsNotReadable checks if a file is not writable func FileIsNotWritable(absolutePath string) bool { f, err := os.OpenFile(path.Clean(absolutePath), os.O_WRONLY, os.ModePerm) if err != nil { return true } defer f.Close() return false } ``` -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile FROM node:20 ARG TZ ENV TZ="$TZ" # Install basic development tools and iptables/ipset RUN apt update && apt install -y less \ git \ procps \ sudo \ fzf \ zsh \ man-db \ unzip \ gnupg2 \ gh \ iptables \ ipset \ iproute2 \ dnsutils \ aggregate \ jq # Ensure default node user has access to /usr/local/share RUN mkdir -p /usr/local/share/npm-global && \ chown -R node:node /usr/local/share ARG USERNAME=node # Persist bash history. RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ && mkdir /commandhistory \ && touch /commandhistory/.bash_history \ && chown -R $USERNAME /commandhistory # Set `DEVCONTAINER` environment variable to help with orientation ENV DEVCONTAINER=true # Create workspace and config directories and set permissions RUN mkdir -p /workspace /home/node/.claude && \ chown -R node:node /workspace /home/node/.claude WORKDIR /workspace RUN ARCH=$(dpkg --print-architecture) && \ wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \ sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \ rm "git-delta_0.18.2_${ARCH}.deb" # Set up non-root user USER node # Install global packages ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global ENV PATH=$PATH:/usr/local/share/npm-global/bin # Set the default shell to zsh rather than sh ENV SHELL=/bin/zsh # Default powerline10k theme RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \ -p git \ -p fzf \ -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \ -a "source /usr/share/doc/fzf/examples/completion.zsh" \ -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ -x # Install Claude RUN npm install -g @anthropic-ai/claude-code # Copy and set up firewall script COPY init-firewall.sh /usr/local/bin/ USER root RUN chmod +x /usr/local/bin/init-firewall.sh && \ echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \ chmod 0440 /etc/sudoers.d/node-firewall USER node ``` -------------------------------------------------------------------------------- /internal/tools/excel_copy_sheet.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "html" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" excel "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" ) type ExcelCopySheetArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SrcSheetName string `zog:"srcSheetName"` DstSheetName string `zog:"dstSheetName"` } var excelCopySheetArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Required(), "srcSheetName": z.String().Required(), "dstSheetName": z.String().Required(), }) func AddExcelCopySheetTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_copy_sheet", mcp.WithDescription("Copy existing sheet to a new sheet"), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("srcSheetName", mcp.Required(), mcp.Description("Source sheet name in the Excel file"), ), mcp.WithString("dstSheetName", mcp.Required(), mcp.Description("Sheet name to be copied"), ), ), handleCopySheet) } func handleCopySheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelCopySheetArguments{} if issues := excelCopySheetArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return copySheet(args.FileAbsolutePath, args.SrcSheetName, args.DstSheetName) } func copySheet(fileAbsolutePath string, srcSheetName string, dstSheetName string) (*mcp.CallToolResult, error) { workbook, release, err := excel.OpenFile(fileAbsolutePath) if err != nil { return nil, err } defer release() srcSheet, err := workbook.FindSheet(srcSheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer srcSheet.Release() srcSheetName, err = srcSheet.Name() if err != nil { return nil, err } if err := workbook.CopySheet(srcSheetName, dstSheetName); err != nil { return nil, err } if err := workbook.Save(); err != nil { return nil, err } result := "# Notice\n" result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName()) result += fmt.Sprintf("Sheet [%s] copied to [%s].\n", html.EscapeString(srcSheetName), html.EscapeString(dstSheetName)) return mcp.NewToolResultText(result), nil } ``` -------------------------------------------------------------------------------- /internal/tools/excel_create_table.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "html" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" excel "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" ) type ExcelCreateTableArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SheetName string `zog:"sheetName"` Range string `zog:"range"` TableName string `zog:"tableName"` } var excelCreateTableArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), "sheetName": z.String().Required(), "range": z.String(), "tableName": z.String().Required(), }) func AddExcelCreateTableTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_create_table", mcp.WithDescription("Create a table in the Excel sheet"), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("sheetName", mcp.Required(), mcp.Description("Sheet name where the table is created"), ), mcp.WithString("range", mcp.Description("Range to be a table (e.g., \"A1:C10\")"), ), mcp.WithString("tableName", mcp.Required(), mcp.Description("Table name to be created"), ), ), handleCreateTable) } func handleCreateTable(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelCreateTableArguments{} if issues := excelCreateTableArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return createTable(args.FileAbsolutePath, args.SheetName, args.Range, args.TableName) } func createTable(fileAbsolutePath string, sheetName string, tableRange string, tableName string) (*mcp.CallToolResult, error) { workbook, release, err := excel.OpenFile(fileAbsolutePath) if err != nil { return nil, err } defer release() worksheet, err := workbook.FindSheet(sheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer worksheet.Release() if err := worksheet.AddTable(tableRange, tableName); err != nil { return nil, err } if err := workbook.Save(); err != nil { return nil, err } result := "# Notice\n" result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName()) result += fmt.Sprintf("Table [%s] created.\n", html.EscapeString(tableName)) return mcp.NewToolResultText(result), nil } ``` -------------------------------------------------------------------------------- /internal/tools/excel_describe_sheets.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "encoding/json" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" ) type ExcelDescribeSheetsArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` } var excelDescribeSheetsArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), }) func AddExcelDescribeSheetsTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_describe_sheets", mcp.WithDescription("List all sheet information of specified Excel file"), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), ), handleDescribeSheets) } func handleDescribeSheets(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelDescribeSheetsArguments{} issues := excelDescribeSheetsArgumentsSchema.Parse(request.Params.Arguments, &args) if len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return describeSheets(args.FileAbsolutePath) } type Response struct { Backend string `json:"backend"` Sheets []Worksheet `json:"sheets"` } type Worksheet struct { Name string `json:"name"` UsedRange string `json:"usedRange"` Tables []Table `json:"tables"` PivotTables []PivotTable `json:"pivotTables"` PagingRanges []string `json:"pagingRanges"` } type Table struct { Name string `json:"name"` Range string `json:"range"` } type PivotTable struct { Name string `json:"name"` Range string `json:"range"` } func describeSheets(fileAbsolutePath string) (*mcp.CallToolResult, error) { config, issues := LoadConfig() if issues != nil { return imcp.NewToolResultZogIssueMap(issues), nil } workbook, release, err := excel.OpenFile(fileAbsolutePath) defer release() if err != nil { return nil, err } sheetList, err := workbook.GetSheets() if err != nil { return nil, err } worksheets := make([]Worksheet, len(sheetList)) for i, sheet := range sheetList { defer sheet.Release() name, err := sheet.Name() if err != nil { return nil, err } usedRange, err := sheet.GetDimention() if err != nil { return nil, err } tables, err := sheet.GetTables() if err != nil { return nil, err } tableList := make([]Table, len(tables)) for i, table := range tables { tableList[i] = Table{ Name: table.Name, Range: table.Range, } } pivotTables, err := sheet.GetPivotTables() if err != nil { return nil, err } pivotTableList := make([]PivotTable, len(pivotTables)) for i, pivotTable := range pivotTables { pivotTableList[i] = PivotTable{ Name: pivotTable.Name, Range: pivotTable.Range, } } var pagingRanges []string strategy, err := sheet.GetPagingStrategy(config.EXCEL_MCP_PAGING_CELLS_LIMIT) if err == nil { pagingService := excel.NewPagingRangeService(strategy) pagingRanges = pagingService.GetPagingRanges() } worksheets[i] = Worksheet{ Name: name, UsedRange: usedRange, Tables: tableList, PivotTables: pivotTableList, PagingRanges: pagingRanges, } } response := Response{ Backend: workbook.GetBackendName(), Sheets: worksheets, } jsonBytes, err := json.MarshalIndent(response, "", " ") if err != nil { return nil, err } return mcp.NewToolResultText(string(jsonBytes)), nil } ``` -------------------------------------------------------------------------------- /internal/tools/excel_screen_capture.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" ) type ExcelScreenCaptureArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SheetName string `zog:"sheetName"` Range string `zog:"range"` } var ExcelScreenCaptureArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), "sheetName": z.String().Required(), "range": z.String(), }) func AddExcelScreenCaptureTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_screen_capture", mcp.WithDescription("[Windows only] Take a screenshot of the Excel sheet with pagination."), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("sheetName", mcp.Required(), mcp.Description("Sheet name in the Excel file"), ), mcp.WithString("range", mcp.Description("Range of cells to read in the Excel sheet (e.g., \"A1:C10\"). [default: first paging range]"), ), ), handleScreenCapture) } func handleScreenCapture(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelScreenCaptureArguments{} issues := ExcelScreenCaptureArgumentsSchema.Parse(request.Params.Arguments, &args) if len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return readSheetImage(args.FileAbsolutePath, args.SheetName, args.Range) } func readSheetImage(fileAbsolutePath string, sheetName string, rangeStr string) (*mcp.CallToolResult, error) { workbook, releaseWorkbook, err := excel.NewExcelOle(fileAbsolutePath) defer releaseWorkbook() if err != nil { workbook, releaseWorkbook, err = excel.NewExcelOleWithNewObject(fileAbsolutePath) defer releaseWorkbook() if err != nil { return imcp.NewToolResultInvalidArgumentError(fmt.Errorf("failed to open workbook: %w", err).Error()), nil } } worksheet, err := workbook.FindSheet(sheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer worksheet.Release() pagingStrategy, err := worksheet.GetPagingStrategy(5000) if err != nil { return nil, err } pagingService := excel.NewPagingRangeService(pagingStrategy) allRanges := pagingService.GetPagingRanges() if len(allRanges) == 0 { return imcp.NewToolResultInvalidArgumentError("no range available to read"), nil } var currentRange string if rangeStr == "" && len(allRanges) > 0 { // range が指定されていない場合は最初の Range を使用 currentRange = allRanges[0] } else { // range が指定されている場合は指定された範囲を使用 currentRange = rangeStr } // Find next paging range if current range matches a paging range nextRange := pagingService.FindNextRange(allRanges, currentRange) base64image, err := worksheet.CapturePicture(currentRange) if err != nil { return nil, fmt.Errorf("failed to copy range to image: %w", err) } text := "# Metadata\n" text += fmt.Sprintf("- backend: %s\n", workbook.GetBackendName()) text += fmt.Sprintf("- sheet name: %s\n", sheetName) text += fmt.Sprintf("- read range: %s\n", currentRange) text += "# Notice\n" if nextRange != "" { text += "This sheet has more ranges.\n" text += "To read the next range, you should specify 'range' argument as follows.\n" text += fmt.Sprintf("`{ \"range\": \"%s\" }`", nextRange) } else { text += "This is the last range or no more ranges available.\n" } // 結果を返却 return mcp.NewToolResultImage( text, base64image, "image/png", ), nil } ``` -------------------------------------------------------------------------------- /.devcontainer/init-firewall.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash set -euo pipefail # Exit on error, undefined vars, and pipeline failures IFS=$'\n\t' # Stricter word splitting # Flush existing rules and delete existing ipsets iptables -F iptables -X iptables -t nat -F iptables -t nat -X iptables -t mangle -F iptables -t mangle -X ipset destroy allowed-domains 2>/dev/null || true # First allow DNS and localhost before any restrictions # Allow outbound DNS iptables -A OUTPUT -p udp --dport 53 -j ACCEPT # Allow inbound DNS responses iptables -A INPUT -p udp --sport 53 -j ACCEPT # Allow outbound SSH iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT # Allow inbound SSH responses iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT # Allow localhost iptables -A INPUT -i lo -j ACCEPT iptables -A OUTPUT -o lo -j ACCEPT # Create ipset with CIDR support ipset create allowed-domains hash:net # Fetch GitHub meta information and aggregate + add their IP ranges echo "Fetching GitHub IP ranges..." gh_ranges=$(curl -s https://api.github.com/meta) if [ -z "$gh_ranges" ]; then echo "ERROR: Failed to fetch GitHub IP ranges" exit 1 fi if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then echo "ERROR: GitHub API response missing required fields" exit 1 fi echo "Processing GitHub IPs..." while read -r cidr; do if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then echo "ERROR: Invalid CIDR range from GitHub meta: $cidr" exit 1 fi echo "Adding GitHub range $cidr" ipset add allowed-domains "$cidr" done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q) # Resolve and add other allowed domains for domain in \ "registry.npmjs.org" \ "api.anthropic.com" \ "sentry.io" \ "statsig.anthropic.com" \ "statsig.com"; do echo "Resolving $domain..." ips=$(dig +short A "$domain") if [ -z "$ips" ]; then echo "ERROR: Failed to resolve $domain" exit 1 fi while read -r ip; do if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then echo "ERROR: Invalid IP from DNS for $domain: $ip" exit 1 fi echo "Adding $ip for $domain" ipset add allowed-domains "$ip" done < <(echo "$ips") done # Get host IP from default route HOST_IP=$(ip route | grep default | cut -d" " -f3) if [ -z "$HOST_IP" ]; then echo "ERROR: Failed to detect host IP" exit 1 fi HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/") echo "Host network detected as: $HOST_NETWORK" # Set up remaining iptables rules iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT # Set default policies to DROP first iptables -P INPUT DROP iptables -P FORWARD DROP iptables -P OUTPUT DROP # First allow established connections for already approved traffic iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT # Then allow only specific outbound traffic to allowed domains iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT echo "Firewall configuration complete" echo "Verifying firewall rules..." if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then echo "ERROR: Firewall verification failed - was able to reach https://example.com" exit 1 else echo "Firewall verification passed - unable to reach https://example.com as expected" fi # Verify GitHub API access if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then echo "ERROR: Firewall verification failed - unable to reach https://api.github.com" exit 1 else echo "Firewall verification passed - able to reach https://api.github.com as expected" fi ``` -------------------------------------------------------------------------------- /internal/tools/excel_write_to_sheet.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" "github.com/xuri/excelize/v2" ) type ExcelWriteToSheetArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SheetName string `zog:"sheetName"` NewSheet bool `zog:"newSheet"` Range string `zog:"range"` Values [][]string `zog:"values"` } var excelWriteToSheetArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), "sheetName": z.String().Required(), "newSheet": z.Bool().Required().Default(false), "range": z.String().Required(), "values": z.Slice(z.Slice(z.String())).Required(), }) func AddExcelWriteToSheetTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_write_to_sheet", mcp.WithDescription("Write values to the Excel sheet"), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("sheetName", mcp.Required(), mcp.Description("Sheet name in the Excel file"), ), mcp.WithBoolean("newSheet", mcp.Required(), mcp.Description("Create a new sheet if true, otherwise write to the existing sheet"), ), mcp.WithString("range", mcp.Required(), mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C10\")"), ), mcp.WithArray("values", mcp.Required(), mcp.Description("Values to write to the Excel sheet. If the value is a formula, it should start with \"=\""), mcp.Items(map[string]any{ "type": "array", "items": map[string]any{ "anyOf": []any{ map[string]any{ "type": "string", }, map[string]any{ "type": "number", }, map[string]any{ "type": "boolean", }, map[string]any{ "type": "null", }, }, }, }), ), ), handleWriteToSheet) } func handleWriteToSheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelWriteToSheetArguments{} issues := excelWriteToSheetArgumentsSchema.Parse(request.Params.Arguments, &args) if len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } // zog が any type のスキーマをサポートしていないため、自力で実装 valuesArg, ok := request.GetArguments()["values"].([]any) if !ok { return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil } values := make([][]any, len(valuesArg)) for i, v := range valuesArg { value, ok := v.([]any) if !ok { return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil } values[i] = value } return writeSheet(args.FileAbsolutePath, args.SheetName, args.NewSheet, args.Range, values) } func writeSheet(fileAbsolutePath string, sheetName string, newSheet bool, rangeStr string, values [][]any) (*mcp.CallToolResult, error) { workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) if err != nil { return nil, err } defer closeFn() startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } // データの整合性チェック rangeRowSize := endRow - startRow + 1 if len(values) != rangeRowSize { return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of rows in data (%d) does not match range size (%d)", len(values), rangeRowSize)), nil } if newSheet { if err := workbook.CreateNewSheet(sheetName); err != nil { return nil, err } } // シートの取得 worksheet, err := workbook.FindSheet(sheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer worksheet.Release() // データの書き込み wroteFormula := false for i, row := range values { rangeColumnSize := endCol - startCol + 1 if len(row) != rangeColumnSize { return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of columns in row %d (%d) does not match range size (%d)", i, len(row), rangeColumnSize)), nil } for j, cellValue := range row { cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) if err != nil { return nil, err } if cellStr, ok := cellValue.(string); ok && isFormula(cellStr) { // if cellValue is formula, set it as formula err = worksheet.SetFormula(cell, cellStr) wroteFormula = true } else { // if cellValue is not formula, set it as value err = worksheet.SetValue(cell, cellValue) } if err != nil { return nil, err } } } if err := workbook.Save(); err != nil { return nil, err } // HTMLテーブルの生成 var table *string if wroteFormula { table, err = CreateHTMLTableOfFormula(worksheet, startCol, startRow, endCol, endRow) } else { table, err = CreateHTMLTableOfValues(worksheet, startCol, startRow, endCol, endRow) } if err != nil { return nil, err } html := "<h2>Written Sheet</h2>\n" html += *table + "\n" html += "<h2>Metadata</h2>\n" html += "<ul>\n" html += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) html += fmt.Sprintf("<li>sheet name: %s</li>\n", sheetName) html += fmt.Sprintf("<li>read range: %s</li>\n", rangeStr) html += "</ul>\n" html += "<h2>Notice</h2>\n" html += "<p>Values wrote successfully.</p>\n" return mcp.NewToolResultText(html), nil } func isFormula(value string) bool { return len(value) > 0 && value[0] == '=' } ``` -------------------------------------------------------------------------------- /internal/tools/excel_read_sheet.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "html" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" excel "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" ) type ExcelReadSheetArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SheetName string `zog:"sheetName"` Range string `zog:"range"` ShowFormula bool `zog:"showFormula"` ShowStyle bool `zog:"showStyle"` } var excelReadSheetArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), "sheetName": z.String().Required(), "range": z.String(), "showFormula": z.Bool().Default(false), "showStyle": z.Bool().Default(false), }) func AddExcelReadSheetTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_read_sheet", mcp.WithDescription("Read values from Excel sheet with pagination."), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("sheetName", mcp.Required(), mcp.Description("Sheet name in the Excel file"), ), mcp.WithString("range", mcp.Description("Range of cells to read in the Excel sheet (e.g., \"A1:C10\"). [default: first paging range]"), ), mcp.WithBoolean("showFormula", mcp.Description("Show formula instead of value"), ), mcp.WithBoolean("showStyle", mcp.Description("Show style information for cells"), ), ), handleReadSheet) } func handleReadSheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelReadSheetArguments{} if issues := excelReadSheetArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return readSheet(args.FileAbsolutePath, args.SheetName, args.Range, args.ShowFormula, args.ShowStyle) } func readSheet(fileAbsolutePath string, sheetName string, valueRange string, showFormula bool, showStyle bool) (*mcp.CallToolResult, error) { config, issues := LoadConfig() if issues != nil { return imcp.NewToolResultZogIssueMap(issues), nil } workbook, release, err := excel.OpenFile(fileAbsolutePath) if err != nil { return nil, err } defer release() worksheet, err := workbook.FindSheet(sheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer worksheet.Release() // ページング戦略の初期化 strategy, err := worksheet.GetPagingStrategy(config.EXCEL_MCP_PAGING_CELLS_LIMIT) if err != nil { return nil, err } pagingService := excel.NewPagingRangeService(strategy) // 利用可能な範囲を取得 allRanges := pagingService.GetPagingRanges() if len(allRanges) == 0 { return imcp.NewToolResultInvalidArgumentError("no range available to read"), nil } // 現在の範囲を決定 currentRange := valueRange if currentRange == "" && len(allRanges) > 0 { currentRange = allRanges[0] } // Find next paging range if current range matches a paging range nextRange := pagingService.FindNextRange(allRanges, currentRange) // Validate the current range against the used range usedRange, err := worksheet.GetDimention() if err != nil { return nil, err } if err := validateRangeWithinUsedRange(currentRange, usedRange); err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } // 範囲を解析 startCol, startRow, endCol, endRow, err := excel.ParseRange(currentRange) if err != nil { return nil, err } // HTMLテーブルの生成 var table *string if showStyle { if showFormula { table, err = CreateHTMLTableOfFormulaWithStyle(worksheet, startCol, startRow, endCol, endRow) } else { table, err = CreateHTMLTableOfValuesWithStyle(worksheet, startCol, startRow, endCol, endRow) } } else { if showFormula { table, err = CreateHTMLTableOfFormula(worksheet, startCol, startRow, endCol, endRow) } else { table, err = CreateHTMLTableOfValues(worksheet, startCol, startRow, endCol, endRow) } } if err != nil { return nil, err } result := "<h2>Read Sheet</h2>\n" result += *table + "\n" result += "<h2>Metadata</h2>\n" result += "<ul>\n" result += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) result += fmt.Sprintf("<li>sheet name: %s</li>\n", html.EscapeString(sheetName)) result += fmt.Sprintf("<li>read range: %s</li>\n", currentRange) result += "</ul>\n" result += "<h2>Notice</h2>\n" if nextRange != "" { result += "<p>This sheet has more ranges.</p>\n" result += "<p>To read the next range, you should specify 'range' argument as follows.</p>\n" result += fmt.Sprintf("<code>{ \"range\": \"%s\" }</code>\n", nextRange) } else { result += "<p>This is the last range or no more ranges available.</p>\n" } return mcp.NewToolResultText(result), nil } func validateRangeWithinUsedRange(targetRange, usedRange string) error { // Parse target range targetStartCol, targetStartRow, targetEndCol, targetEndRow, err := excel.ParseRange(targetRange) if err != nil { return fmt.Errorf("failed to parse target range: %w", err) } // Parse used range usedStartCol, usedStartRow, usedEndCol, usedEndRow, err := excel.ParseRange(usedRange) if err != nil { return fmt.Errorf("failed to parse used range: %w", err) } // Check if target range is within used range if targetStartCol < usedStartCol || targetStartRow < usedStartRow || targetEndCol > usedEndCol || targetEndRow > usedEndRow { return fmt.Errorf("range is outside of used range: %s is not within %s", targetRange, usedRange) } return nil } ``` -------------------------------------------------------------------------------- /docs/design/excel-style-schema.md: -------------------------------------------------------------------------------- ```markdown # MCP Excel Style Structure Definition This document presents JsonSchema definitions for exchanging Excel styles through MCP (Model Context Protocol), based on the Excelize library's style API. ## Target Style Elements - Border - Font - Fill - NumFmt (Number Format) - DecimalPlaces ## JsonSchema Definition ### ExcelStyle Structure ```json { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "title": "ExcelStyle", "description": "Excel cell style configuration", "properties": { "border": { "type": "array", "description": "Border configuration for cell edges", "items": { "$ref": "#/definitions/Border" } }, "font": { "$ref": "#/definitions/Font", "description": "Font configuration" }, "fill": { "$ref": "#/definitions/Fill", "description": "Fill pattern and color configuration" }, "numFmt": { "type": "string", "description": "Custom number format string", "example": "#,##0.00" }, "decimalPlaces": { "type": "integer", "description": "Number of decimal places (0-30)", "minimum": 0, "maximum": 30 } }, "definitions": { "Border": { "type": "object", "description": "Border style configuration", "properties": { "type": { "type": "string", "description": "Border position", "enum": ["left", "right", "top", "bottom", "diagonalDown", "diagonalUp"] }, "color": { "type": "string", "description": "Border color in hex format", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#000000" }, "style": { "type": "string", "description": "Border style", "enum": ["none", "continuous", "dash", "dashDot", "dashDotDot", "dot", "double", "hair", "medium", "mediumDash", "mediumDashDot", "mediumDashDotDot", "slantDashDot", "thick"] } }, "required": ["type"], "additionalProperties": false }, "Font": { "type": "object", "description": "Font style configuration", "properties": { "bold": { "type": "boolean", "description": "Bold text" }, "italic": { "type": "boolean", "description": "Italic text" }, "underline": { "type": "string", "description": "Underline style", "enum": ["none", "single", "double", "singleAccounting", "doubleAccounting"] }, "size": { "type": "number", "description": "Font size in points", "minimum": 1, "maximum": 409 }, "strike": { "type": "boolean", "description": "Strikethrough text" }, "color": { "type": "string", "description": "Font color in hex format", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#000000" }, "vertAlign": { "type": "string", "description": "Vertical alignment", "enum": ["baseline", "superscript", "subscript"] } }, "additionalProperties": false }, "Fill": { "type": "object", "description": "Fill pattern and color configuration", "properties": { "type": { "type": "string", "description": "Fill type", "enum": ["gradient", "pattern"] }, "pattern": { "type": "string", "description": "Pattern style", "enum": ["none", "solid", "mediumGray", "darkGray", "lightGray", "darkHorizontal", "darkVertical", "darkDown", "darkUp", "darkGrid", "darkTrellis", "lightHorizontal", "lightVertical", "lightDown", "lightUp", "lightGrid", "lightTrellis", "gray125", "gray0625"] }, "color": { "type": "array", "description": "Fill colors in hex format", "items": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$", "example": "#FFFFFF" } }, "shading": { "type": "string", "description": "Gradient shading direction", "enum": ["horizontal", "vertical", "diagonalDown", "diagonalUp", "fromCenter", "fromCorner"] } }, "additionalProperties": false } } } ``` ## Usage Examples ### Basic Style Configuration ```json { "font": { "bold": true, "size": 12, "color": "#000000" }, "fill": { "type": "pattern", "pattern": "solid", "color": ["#FFFF00"] } } ``` ### Style with Borders ```json { "border": [ { "type": "top", "style": "continuous", "color": "#000000" }, { "type": "bottom", "style": "continuous", "color": "#000000" } ], "font": { "size": 10 } } ``` ### Style with Number Format ```json { "numFmt": "#,##0.00", "decimalPlaces": 2, "font": { "size": 11 } } ``` ## Implementation Notes 1. **Required Fields**: Only Border's `type` field is required; all others are optional 2. **Color Format**: Hexadecimal color codes (#RRGGBB format) 3. **Numeric Limits**: - `decimalPlaces`: Range 0-30 - `border.style`: String identifiers (none, continuous, dash, etc.) - `fill.pattern`: String identifiers (none, solid, mediumGray, etc.) - `fill.shading`: String identifiers (horizontal, vertical, etc.) - `font.size`: Range 1-409 4. **Testing**: After implementation, test with actual Excel files ## Correspondence with Excelize This definition is based on the style structure of `github.com/xuri/excelize/v2 v2.9.0` and maintains compatibility with Excelize's API specifications. ``` -------------------------------------------------------------------------------- /internal/tools/excel_format_range.go: -------------------------------------------------------------------------------- ```go package tools import ( "context" "fmt" "regexp" z "github.com/Oudwins/zog" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/negokaz/excel-mcp-server/internal/excel" imcp "github.com/negokaz/excel-mcp-server/internal/mcp" "github.com/xuri/excelize/v2" ) type ExcelFormatRangeArguments struct { FileAbsolutePath string `zog:"fileAbsolutePath"` SheetName string `zog:"sheetName"` Range string `zog:"range"` Styles [][]*excel.CellStyle `zog:"styles"` } var colorPattern, _ = regexp.Compile("^#[0-9A-Fa-f]{6}$") var excelFormatRangeArgumentsSchema = z.Struct(z.Shape{ "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), "sheetName": z.String().Required(), "range": z.String().Required(), "styles": z.Slice(z.Slice( z.Ptr(z.Struct(z.Shape{ "border": z.Slice(z.Struct(z.Shape{ "type": z.StringLike[excel.BorderType]().OneOf(excel.BorderTypeValues()).Required(), "color": z.String().Match(colorPattern).Default("#000000"), "style": z.StringLike[excel.BorderStyle]().OneOf(excel.BorderStyleValues()).Default(excel.BorderStyleContinuous), })).Default([]excel.Border{}), "font": z.Ptr(z.Struct(z.Shape{ "bold": z.Ptr(z.Bool()), "italic": z.Ptr(z.Bool()), "underline": z.Ptr(z.StringLike[excel.FontUnderline]().OneOf(excel.FontUnderlineValues())), "size": z.Ptr(z.Int().GTE(1).LTE(409)), "strike": z.Ptr(z.Bool()), "color": z.Ptr(z.String().Match(colorPattern)), "vertAlign": z.Ptr(z.StringLike[excel.FontVertAlign]().OneOf(excel.FontVertAlignValues())), })), "fill": z.Ptr(z.Struct(z.Shape{ "type": z.StringLike[excel.FillType]().OneOf(excel.FillTypeValues()).Default(excel.FillTypePattern), "pattern": z.StringLike[excel.FillPattern]().OneOf(excel.FillPatternValues()).Default(excel.FillPatternSolid), "color": z.Slice(z.String().Match(colorPattern)).Default([]string{}), "shading": z.Ptr(z.StringLike[excel.FillShading]().OneOf(excel.FillShadingValues())), })), "numFmt": z.Ptr(z.String()), "decimalPlaces": z.Ptr(z.Int().GTE(0).LTE(30)), }), ))).Required(), }) func AddExcelFormatRangeTool(server *server.MCPServer) { server.AddTool(mcp.NewTool("excel_format_range", mcp.WithDescription("Format cells in the Excel sheet with style information"), mcp.WithString("fileAbsolutePath", mcp.Required(), mcp.Description("Absolute path to the Excel file"), ), mcp.WithString("sheetName", mcp.Required(), mcp.Description("Sheet name in the Excel file"), ), mcp.WithString("range", mcp.Required(), mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C3\")"), ), mcp.WithArray("styles", mcp.Required(), mcp.Description("2D array of style objects for each cell. If a cell does not change style, use null. The number of items of the array must match the range size."), mcp.Items(map[string]any{ "type": "array", "items": map[string]any{ "anyOf": []any{ map[string]any{ "type": "object", "description": "Style object for the cell", "properties": map[string]any{ "border": map[string]any{ "type": "array", "items": map[string]any{ "type": "object", "properties": map[string]any{ "type": map[string]any{ "type": "string", "enum": excel.BorderTypeValues(), }, "color": map[string]any{ "type": "string", "pattern": colorPattern.String(), }, "style": map[string]any{ "type": "string", "enum": excel.BorderStyleValues(), }, }, "required": []string{"type"}, }, }, "font": map[string]any{ "type": "object", "properties": map[string]any{ "bold": map[string]any{"type": "boolean"}, "italic": map[string]any{"type": "boolean"}, "underline": map[string]any{ "type": "string", "enum": excel.FontUnderlineValues(), }, "size": map[string]any{ "type": "number", "minimum": 1, "maximum": 409, }, "strike": map[string]any{"type": "boolean"}, "color": map[string]any{ "type": "string", "pattern": colorPattern.String(), }, "vertAlign": map[string]any{ "type": "string", "enum": excel.FontVertAlignValues(), }, }, }, "fill": map[string]any{ "type": "object", "properties": map[string]any{ "type": map[string]any{ "type": "string", "enum": []string{"gradient", "pattern"}, }, "pattern": map[string]any{ "type": "string", "enum": excel.FillPatternValues(), }, "color": map[string]any{ "type": "array", "items": map[string]any{ "type": "string", "pattern": colorPattern.String(), }, }, "shading": map[string]any{ "type": "string", "enum": excel.FillShadingValues(), }, }, "required": []string{"type", "pattern", "color"}, }, "numFmt": map[string]any{ "type": "string", "description": "Custom number format string", }, "decimalPlaces": map[string]any{ "type": "integer", "minimum": 0, "maximum": 30, }, }, }, map[string]any{ "type": "null", "description": "No style applied to this cell", }, }, }, }), ), ), handleFormatRange) } func handleFormatRange(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := ExcelFormatRangeArguments{} issues := excelFormatRangeArgumentsSchema.Parse(request.Params.Arguments, &args) if len(issues) != 0 { return imcp.NewToolResultZogIssueMap(issues), nil } return formatRange(args.FileAbsolutePath, args.SheetName, args.Range, args.Styles) } func formatRange(fileAbsolutePath string, sheetName string, rangeStr string, styles [][]*excel.CellStyle) (*mcp.CallToolResult, error) { workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) if err != nil { return nil, err } defer closeFn() startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } // Check data consistency rangeRowSize := endRow - startRow + 1 if len(styles) != rangeRowSize { return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style rows (%d) does not match range size (%d)", len(styles), rangeRowSize)), nil } // Get worksheet worksheet, err := workbook.FindSheet(sheetName) if err != nil { return imcp.NewToolResultInvalidArgumentError(err.Error()), nil } defer worksheet.Release() // Apply styles to each cell for i, styleRow := range styles { rangeColumnSize := endCol - startCol + 1 if len(styleRow) != rangeColumnSize { return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style columns in row %d (%d) does not match range size (%d)", i, len(styleRow), rangeColumnSize)), nil } for j, style := range styleRow { cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) if err != nil { return nil, err } if style != nil { if err := worksheet.SetCellStyle(cell, style); err != nil { return nil, fmt.Errorf("failed to set style for cell %s: %w", cell, err) } } } } if err := workbook.Save(); err != nil { return nil, err } // Create response HTML html := "<h2>Formatted Range</h2>\n" html += fmt.Sprintf("<p>Successfully applied styles to range %s in sheet %s</p>\n", rangeStr, sheetName) html += "<h2>Metadata</h2>\n" html += "<ul>\n" html += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) html += fmt.Sprintf("<li>sheet name: %s</li>\n", sheetName) html += fmt.Sprintf("<li>formatted range: %s</li>\n", rangeStr) html += fmt.Sprintf("<li>cells processed: %d</li>\n", (endRow-startRow+1)*(endCol-startCol+1)) html += "</ul>\n" html += "<h2>Notice</h2>\n" html += "<p>Cell styles applied successfully.</p>\n" return mcp.NewToolResultText(html), nil } ``` -------------------------------------------------------------------------------- /internal/excel/excel.go: -------------------------------------------------------------------------------- ```go package excel import ( "github.com/xuri/excelize/v2" ) type Excel interface { // GetBackendName returns the backend used to manipulate the Excel file. GetBackendName() string // GetSheets returns a list of all worksheets in the Excel file. GetSheets() ([]Worksheet, error) // FindSheet finds a sheet by its name and returns a Worksheet. FindSheet(sheetName string) (Worksheet, error) // CreateNewSheet creates a new sheet with the specified name. CreateNewSheet(sheetName string) error // CopySheet copies a sheet from one to another. CopySheet(srcSheetName, destSheetName string) error // Save saves the Excel file. Save() error } type Worksheet interface { // Release releases the worksheet resources. Release() // Name returns the name of the worksheet. Name() (string, error) // GetTable returns a tables in this worksheet. GetTables() ([]Table, error) // GetPivotTable returns a pivot tables in this worksheet. GetPivotTables() ([]PivotTable, error) // SetValue sets a value in the specified cell. SetValue(cell string, value any) error // SetFormula sets a formula in the specified cell. SetFormula(cell string, formula string) error // GetValue gets the value from the specified cell. GetValue(cell string) (string, error) // GetFormula gets the formula from the specified cell. GetFormula(cell string) (string, error) // GetDimention gets the dimension of the worksheet. GetDimention() (string, error) // GetPagingStrategy returns the paging strategy for the worksheet. // The pageSize parameter is used to determine the max size of each page. GetPagingStrategy(pageSize int) (PagingStrategy, error) // CapturePicture returns base64 encoded image data of the specified range. CapturePicture(captureRange string) (string, error) // AddTable adds a table to this worksheet. AddTable(tableRange, tableName string) error // GetCellStyle gets style information for the specified cell. GetCellStyle(cell string) (*CellStyle, error) // SetCellStyle sets style for the specified cell. SetCellStyle(cell string, style *CellStyle) error } type Table struct { Name string Range string } type PivotTable struct { Name string Range string } type CellStyle struct { Border []Border `yaml:"border,omitempty"` Font *FontStyle `yaml:"font,omitempty"` Fill *FillStyle `yaml:"fill,omitempty"` NumFmt *string `yaml:"numFmt,omitempty"` DecimalPlaces *int `yaml:"decimalPlaces,omitempty"` } type Border struct { Type BorderType `yaml:"type"` Style BorderStyle `yaml:"style,omitempty"` Color string `yaml:"color,omitempty"` } type FontStyle struct { Bold *bool `yaml:"bold,omitempty"` Italic *bool `yaml:"italic,omitempty"` Underline *FontUnderline `yaml:"underline,omitempty"` Size *int `yaml:"size,omitempty"` Strike *bool `yaml:"strike,omitempty"` Color *string `yaml:"color,omitempty"` VertAlign *FontVertAlign `yaml:"vertAlign,omitempty"` } type FillStyle struct { Type FillType `yaml:"type,omitempty"` Pattern FillPattern `yaml:"pattern,omitempty"` Color []string `yaml:"color,omitempty"` Shading *FillShading `yaml:"shading,omitempty"` } // OpenFile opens an Excel file and returns an Excel interface. // It first tries to open the file using OLE automation, and if that fails, // it tries to using the excelize library. func OpenFile(absoluteFilePath string) (Excel, func(), error) { ole, releaseFn, err := NewExcelOle(absoluteFilePath) if err == nil { return ole, releaseFn, nil } // If OLE fails, try Excelize workbook, err := excelize.OpenFile(absoluteFilePath) if err != nil { return nil, func() {}, err } excelize := NewExcelizeExcel(workbook) return excelize, func() { workbook.Close() }, nil } // BorderType represents border direction type BorderType string const ( BorderTypeLeft BorderType = "left" BorderTypeRight BorderType = "right" BorderTypeTop BorderType = "top" BorderTypeBottom BorderType = "bottom" BorderTypeDiagonalDown BorderType = "diagonalDown" BorderTypeDiagonalUp BorderType = "diagonalUp" ) func (b BorderType) String() string { return string(b) } func (b BorderType) MarshalText() ([]byte, error) { return []byte(b.String()), nil } func BorderTypeValues() []BorderType { return []BorderType{ BorderTypeLeft, BorderTypeRight, BorderTypeTop, BorderTypeBottom, BorderTypeDiagonalDown, BorderTypeDiagonalUp, } } // BorderStyle represents border style constants type BorderStyle string const ( BorderStyleNone BorderStyle = "none" BorderStyleContinuous BorderStyle = "continuous" BorderStyleDash BorderStyle = "dash" BorderStyleDot BorderStyle = "dot" BorderStyleDouble BorderStyle = "double" BorderStyleDashDot BorderStyle = "dashDot" BorderStyleDashDotDot BorderStyle = "dashDotDot" BorderStyleSlantDashDot BorderStyle = "slantDashDot" BorderStyleMediumDashDot BorderStyle = "mediumDashDot" BorderStyleMediumDashDotDot BorderStyle = "mediumDashDotDot" ) func (b BorderStyle) String() string { return string(b) } func (b BorderStyle) MarshalText() ([]byte, error) { return []byte(b.String()), nil } func BorderStyleValues() []BorderStyle { return []BorderStyle{ BorderStyleNone, BorderStyleContinuous, BorderStyleDash, BorderStyleDot, BorderStyleDouble, BorderStyleDashDot, BorderStyleDashDotDot, BorderStyleSlantDashDot, BorderStyleMediumDashDot, BorderStyleMediumDashDotDot, } } // FontUnderline represents underline styles for font type FontUnderline string const ( FontUnderlineNone FontUnderline = "none" FontUnderlineSingle FontUnderline = "single" FontUnderlineDouble FontUnderline = "double" FontUnderlineSingleAccounting FontUnderline = "singleAccounting" FontUnderlineDoubleAccounting FontUnderline = "doubleAccounting" ) func (f FontUnderline) String() string { return string(f) } func (f FontUnderline) MarshalText() ([]byte, error) { return []byte(f.String()), nil } func FontUnderlineValues() []FontUnderline { return []FontUnderline{ FontUnderlineNone, FontUnderlineSingle, FontUnderlineDouble, FontUnderlineSingleAccounting, FontUnderlineDoubleAccounting, } } // FontVertAlign represents vertical alignment options for font styles type FontVertAlign string const ( FontVertAlignBaseline FontVertAlign = "baseline" FontVertAlignSuperscript FontVertAlign = "superscript" FontVertAlignSubscript FontVertAlign = "subscript" ) func (v FontVertAlign) String() string { return string(v) } func (v FontVertAlign) MarshalText() ([]byte, error) { return []byte(v.String()), nil } func FontVertAlignValues() []FontVertAlign { return []FontVertAlign{ FontVertAlignBaseline, FontVertAlignSuperscript, FontVertAlignSubscript, } } // FillType represents fill types for cell styles type FillType string const ( FillTypeGradient FillType = "gradient" FillTypePattern FillType = "pattern" ) func (f FillType) String() string { return string(f) } func (f FillType) MarshalText() ([]byte, error) { return []byte(f.String()), nil } func FillTypeValues() []FillType { return []FillType{ FillTypeGradient, FillTypePattern, } } // FillPattern represents fill pattern constants type FillPattern string const ( FillPatternNone FillPattern = "none" FillPatternSolid FillPattern = "solid" FillPatternMediumGray FillPattern = "mediumGray" FillPatternDarkGray FillPattern = "darkGray" FillPatternLightGray FillPattern = "lightGray" FillPatternDarkHorizontal FillPattern = "darkHorizontal" FillPatternDarkVertical FillPattern = "darkVertical" FillPatternDarkDown FillPattern = "darkDown" FillPatternDarkUp FillPattern = "darkUp" FillPatternDarkGrid FillPattern = "darkGrid" FillPatternDarkTrellis FillPattern = "darkTrellis" FillPatternLightHorizontal FillPattern = "lightHorizontal" FillPatternLightVertical FillPattern = "lightVertical" FillPatternLightDown FillPattern = "lightDown" FillPatternLightUp FillPattern = "lightUp" FillPatternLightGrid FillPattern = "lightGrid" FillPatternLightTrellis FillPattern = "lightTrellis" FillPatternGray125 FillPattern = "gray125" FillPatternGray0625 FillPattern = "gray0625" ) func (f FillPattern) String() string { return string(f) } func (f FillPattern) MarshalText() ([]byte, error) { return []byte(f.String()), nil } func FillPatternValues() []FillPattern { return []FillPattern{ FillPatternNone, FillPatternSolid, FillPatternMediumGray, FillPatternDarkGray, FillPatternLightGray, FillPatternDarkHorizontal, FillPatternDarkVertical, FillPatternDarkDown, FillPatternDarkUp, FillPatternDarkGrid, FillPatternDarkTrellis, FillPatternLightHorizontal, FillPatternLightVertical, FillPatternLightDown, FillPatternLightUp, FillPatternLightGrid, FillPatternLightTrellis, FillPatternGray125, FillPatternGray0625, } } // FillShading represents fill shading constants type FillShading string const ( FillShadingHorizontal FillShading = "horizontal" FillShadingVertical FillShading = "vertical" FillShadingDiagonalDown FillShading = "diagonalDown" FillShadingDiagonalUp FillShading = "diagonalUp" FillShadingFromCenter FillShading = "fromCenter" FillShadingFromCorner FillShading = "fromCorner" ) func (f FillShading) String() string { return string(f) } func (f FillShading) MarshalText() ([]byte, error) { return []byte(f.String()), nil } func FillShadingValues() []FillShading { return []FillShading{ FillShadingHorizontal, FillShadingVertical, FillShadingDiagonalDown, FillShadingDiagonalUp, FillShadingFromCenter, FillShadingFromCorner, } } ``` -------------------------------------------------------------------------------- /internal/tools/common.go: -------------------------------------------------------------------------------- ```go package tools import ( "crypto/md5" "fmt" "html" "path/filepath" "slices" "strconv" "strings" "github.com/goccy/go-yaml" "github.com/xuri/excelize/v2" "github.com/negokaz/excel-mcp-server/internal/excel" z "github.com/Oudwins/zog" ) type StyleRegistry struct { // Border styles borderStyles map[string]string // styleID -> YAML string borderHashToID map[string]string // styleHash -> styleID borderCounter int // Font styles fontStyles map[string]string // styleID -> YAML string fontHashToID map[string]string // styleHash -> styleID fontCounter int // Fill styles fillStyles map[string]string // styleID -> YAML string fillHashToID map[string]string // styleHash -> styleID fillCounter int // Number format styles numFmtStyles map[string]string // styleID -> NumFmt numFmtHashToID map[string]string // styleHash -> styleID numFmtCounter int // Decimal places styles decimalStyles map[string]string // styleID -> YAML string decimalHashToID map[string]string // styleHash -> styleID decimalCounter int } func NewStyleRegistry() *StyleRegistry { return &StyleRegistry{ borderStyles: make(map[string]string), borderHashToID: make(map[string]string), borderCounter: 0, fontStyles: make(map[string]string), fontHashToID: make(map[string]string), fontCounter: 0, fillStyles: make(map[string]string), fillHashToID: make(map[string]string), fillCounter: 0, numFmtStyles: make(map[string]string), numFmtHashToID: make(map[string]string), numFmtCounter: 0, decimalStyles: make(map[string]string), decimalHashToID: make(map[string]string), decimalCounter: 0, } } func (sr *StyleRegistry) RegisterStyle(cellStyle *excel.CellStyle) []string { if cellStyle == nil || sr.isEmptyStyle(cellStyle) { return []string{} } var styleIDs []string // Register border style if len(cellStyle.Border) > 0 { if borderID := sr.RegisterBorderStyle(cellStyle.Border); borderID != "" { styleIDs = append(styleIDs, borderID) } } // Register font style if cellStyle.Font != nil { if fontID := sr.RegisterFontStyle(cellStyle.Font); fontID != "" { styleIDs = append(styleIDs, fontID) } } // Register fill style if cellStyle.Fill != nil && cellStyle.Fill.Type != "" { if fillID := sr.RegisterFillStyle(cellStyle.Fill); fillID != "" { styleIDs = append(styleIDs, fillID) } } // Register number format style if cellStyle.NumFmt != nil && *cellStyle.NumFmt != "" { if numFmtID := sr.RegisterNumFmtStyle(*cellStyle.NumFmt); numFmtID != "" { styleIDs = append(styleIDs, numFmtID) } } // Register decimal places style if cellStyle.DecimalPlaces != nil && *cellStyle.DecimalPlaces != 0 { if decimalID := sr.RegisterDecimalStyle(*cellStyle.DecimalPlaces); decimalID != "" { styleIDs = append(styleIDs, decimalID) } } return styleIDs } func (sr *StyleRegistry) isEmptyStyle(style *excel.CellStyle) bool { if len(style.Border) > 0 || style.Font != nil || (style.NumFmt != nil && *style.NumFmt != "") || (style.DecimalPlaces != nil && *style.DecimalPlaces != 0) { return false } if style.Fill != nil && style.Fill.Type != "" { return false } return true } // calculateYamlHash calculates a hash for a YAML string func calculateYamlHash(yaml string) string { if yaml == "" { return "" } hash := md5.Sum([]byte(yaml)) return fmt.Sprintf("%x", hash)[:8] } // Individual style element registration methods func (sr *StyleRegistry) RegisterBorderStyle(borders []excel.Border) string { if len(borders) == 0 { return "" } yamlStr := convertToYAMLFlow(borders) if yamlStr == "" { return "" } styleHash := calculateYamlHash(yamlStr) if styleHash == "" { return "" } if existingID, exists := sr.borderHashToID[styleHash]; exists { return existingID } sr.borderCounter++ styleID := fmt.Sprintf("b%d", sr.borderCounter) sr.borderStyles[styleID] = yamlStr sr.borderHashToID[styleHash] = styleID return styleID } func (sr *StyleRegistry) RegisterFontStyle(font *excel.FontStyle) string { if font == nil { return "" } yamlStr := convertToYAMLFlow(font) if yamlStr == "" { return "" } styleHash := calculateYamlHash(yamlStr) if styleHash == "" { return "" } if existingID, exists := sr.fontHashToID[styleHash]; exists { return existingID } sr.fontCounter++ styleID := fmt.Sprintf("f%d", sr.fontCounter) sr.fontStyles[styleID] = yamlStr sr.fontHashToID[styleHash] = styleID return styleID } func (sr *StyleRegistry) RegisterFillStyle(fill *excel.FillStyle) string { if fill == nil || fill.Type == "" { return "" } yamlStr := convertToYAMLFlow(fill) if yamlStr == "" { return "" } styleHash := calculateYamlHash(yamlStr) if styleHash == "" { return "" } if existingID, exists := sr.fillHashToID[styleHash]; exists { return existingID } sr.fillCounter++ styleID := fmt.Sprintf("l%d", sr.fillCounter) sr.fillStyles[styleID] = yamlStr sr.fillHashToID[styleHash] = styleID return styleID } func (sr *StyleRegistry) RegisterNumFmtStyle(numFmt string) string { if numFmt == "" { return "" } styleHash := calculateYamlHash(numFmt) if styleHash == "" { return "" } if existingID, exists := sr.numFmtHashToID[styleHash]; exists { return existingID } sr.numFmtCounter++ styleID := fmt.Sprintf("n%d", sr.numFmtCounter) sr.numFmtStyles[styleID] = numFmt sr.numFmtHashToID[styleHash] = styleID return styleID } func (sr *StyleRegistry) RegisterDecimalStyle(decimal int) string { if decimal == 0 { return "" } yamlStr := convertToYAMLFlow(decimal) if yamlStr == "" { return "" } styleHash := calculateYamlHash(yamlStr) if styleHash == "" { return "" } if existingID, exists := sr.decimalHashToID[styleHash]; exists { return existingID } sr.decimalCounter++ styleID := fmt.Sprintf("d%d", sr.decimalCounter) sr.decimalStyles[styleID] = yamlStr sr.decimalHashToID[styleHash] = styleID return styleID } func (sr *StyleRegistry) GenerateStyleDefinitions() string { totalCount := len(sr.borderStyles) + len(sr.fontStyles) + len(sr.fillStyles) + len(sr.numFmtStyles) + len(sr.decimalStyles) if totalCount == 0 { return "" } var result strings.Builder result.WriteString("<h2>Style Definitions</h2>\n") result.WriteString("<div class=\"style-definitions\">\n") // Generate border style definitions result.WriteString(sr.generateStyleDefTag(sr.borderStyles, "border")) // Generate font style definitions result.WriteString(sr.generateStyleDefTag(sr.fontStyles, "font")) // Generate fill style definitions result.WriteString(sr.generateStyleDefTag(sr.fillStyles, "fill")) // Generate number format style definitions result.WriteString(sr.generateStyleDefTag(sr.numFmtStyles, "numFmt")) // Generate decimal places style definitions result.WriteString(sr.generateStyleDefTag(sr.decimalStyles, "decimalPlaces")) result.WriteString("</div>\n\n") return result.String() } func (sr *StyleRegistry) generateStyleDefTag(styles map[string]string, styleLabel string) string { if len(styles) == 0 { return "" } var styleIDs []string for styleID := range styles { styleIDs = append(styleIDs, styleID) } sortStyleIDs(styleIDs) var result strings.Builder for _, styleID := range styleIDs { yamlStr := styles[styleID] if yamlStr != "" { result.WriteString(fmt.Sprintf("<code class=\"style language-yaml\" id=\"%s\">%s: %s</code>\n", styleID, styleLabel, html.EscapeString(yamlStr))) } } return result.String() } func sortStyleIDs(styleIDs []string) { slices.SortFunc(styleIDs, func(a, b string) int { // styleID must have number suffix after prefix ai, _ := strconv.Atoi(a[1:]) bi, _ := strconv.Atoi(b[1:]) return ai - bi }) } // Common function to convert any value to YAML flow format func convertToYAMLFlow(value any) string { if value == nil { return "" } yamlBytes, err := yaml.MarshalWithOptions(value, yaml.Flow(true), yaml.OmitEmpty()) if err != nil { return "" } yamlStr := strings.TrimSpace(strings.ReplaceAll(string(yamlBytes), "\"", "")) return yamlStr } func CreateHTMLTableOfValues(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { return worksheet.GetValue(cellRange) }) } func CreateHTMLTableOfFormula(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { return worksheet.GetFormula(cellRange) }) } // CreateHTMLTable creates a table data in HTML format func createHTMLTable(startCol int, startRow int, endCol int, endRow int, extractor func(cellRange string) (string, error)) (*string, error) { return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, extractor, nil) } func CreateHTMLTableOfValuesWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { return worksheet.GetValue(cellRange) }, func(cellRange string) (*excel.CellStyle, error) { return worksheet.GetCellStyle(cellRange) }) } func CreateHTMLTableOfFormulaWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { return worksheet.GetFormula(cellRange) }, func(cellRange string) (*excel.CellStyle, error) { return worksheet.GetCellStyle(cellRange) }) } func createHTMLTableWithStyle(startCol int, startRow int, endCol int, endRow int, extractor func(cellRange string) (string, error), styleExtractor func(cellRange string) (*excel.CellStyle, error)) (*string, error) { registry := NewStyleRegistry() // データとスタイルを収集 var result strings.Builder result.WriteString("<table>\n<tr><th></th>") // 列アドレスの出力 for col := startCol; col <= endCol; col++ { name, _ := excelize.ColumnNumberToName(col) result.WriteString(fmt.Sprintf("<th>%s</th>", name)) } result.WriteString("</tr>\n") // データの出力とスタイル登録 for row := startRow; row <= endRow; row++ { result.WriteString("<tr>") result.WriteString(fmt.Sprintf("<th>%d</th>", row)) for col := startCol; col <= endCol; col++ { axis, _ := excelize.CoordinatesToCellName(col, row) value, _ := extractor(axis) var tdTag string if styleExtractor != nil { cellStyle, err := styleExtractor(axis) if err == nil && cellStyle != nil { styleIDs := registry.RegisterStyle(cellStyle) if len(styleIDs) > 0 { tdTag = fmt.Sprintf("<td style-ref=\"%s\">", strings.Join(styleIDs, " ")) } else { tdTag = "<td>" } } else { tdTag = "<td>" } } else { tdTag = "<td>" } result.WriteString(fmt.Sprintf("%s%s</td>", tdTag, strings.ReplaceAll(html.EscapeString(value), "\n", "<br>"))) } result.WriteString("</tr>\n") } result.WriteString("</table>") // スタイル定義とテーブルを結合 var finalResult strings.Builder styleDefinitions := registry.GenerateStyleDefinitions() if styleDefinitions != "" { finalResult.WriteString(styleDefinitions) } finalResult.WriteString("<h2>Sheet Data</h2>\n") finalResult.WriteString(result.String()) finalResultStr := finalResult.String() return &finalResultStr, nil } func AbsolutePathTest() z.Test[*string] { return z.Test[*string]{ Func: func(path *string, ctx z.Ctx) { if !filepath.IsAbs(*path) { ctx.AddIssue(ctx.Issue().SetMessage(fmt.Sprintf("Path '%s' is not absolute", *path))) } }, } } ``` -------------------------------------------------------------------------------- /internal/excel/excel_excelize.go: -------------------------------------------------------------------------------- ```go package excel import ( "fmt" "os" "path/filepath" "strings" "github.com/xuri/excelize/v2" ) type ExcelizeExcel struct { file *excelize.File } func NewExcelizeExcel(file *excelize.File) Excel { return &ExcelizeExcel{file: file} } func (e *ExcelizeExcel) GetBackendName() string { return "excelize" } func (e *ExcelizeExcel) FindSheet(sheetName string) (Worksheet, error) { index, err := e.file.GetSheetIndex(sheetName) if err != nil { return nil, fmt.Errorf("sheet not found: %s", sheetName) } if index < 0 { return nil, fmt.Errorf("sheet not found: %s", sheetName) } return &ExcelizeWorksheet{file: e.file, sheetName: sheetName}, nil } func (e *ExcelizeExcel) CreateNewSheet(sheetName string) error { _, err := e.file.NewSheet(sheetName) if err != nil { return fmt.Errorf("failed to create new sheet: %w", err) } return nil } func (e *ExcelizeExcel) CopySheet(srcSheetName string, destSheetName string) error { srcIndex, err := e.file.GetSheetIndex(srcSheetName) if srcIndex < 0 { return fmt.Errorf("source sheet not found: %s", srcSheetName) } if err != nil { return err } destIndex, err := e.file.NewSheet(destSheetName) if err != nil { return fmt.Errorf("failed to create destination sheet: %w", err) } if err := e.file.CopySheet(srcIndex, destIndex); err != nil { return fmt.Errorf("failed to copy sheet: %w", err) } srcNext := e.file.GetSheetList()[srcIndex+1] if srcNext != srcSheetName { e.file.MoveSheet(destSheetName, srcNext) } return nil } func (e *ExcelizeExcel) GetSheets() ([]Worksheet, error) { sheetList := e.file.GetSheetList() worksheets := make([]Worksheet, len(sheetList)) for i, sheetName := range sheetList { worksheets[i] = &ExcelizeWorksheet{file: e.file, sheetName: sheetName} } return worksheets, nil } // SaveExcelize saves the Excel file to the specified path. // Excelize's Save method restricts the file path length to 207 characters, // but since this limitation has been relaxed in some environments, // we ignore this restriction. // https://github.com/qax-os/excelize/blob/v2.9.0/file.go#L71-L73 func (w *ExcelizeExcel) Save() error { file, err := os.OpenFile(filepath.Clean(w.file.Path), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, os.ModePerm) if err != nil { return err } defer file.Close() return w.file.Write(file) } type ExcelizeWorksheet struct { file *excelize.File sheetName string } func (w *ExcelizeWorksheet) Release() { // No resources to release in excelize } func (w *ExcelizeWorksheet) Name() (string, error) { return w.sheetName, nil } func (w *ExcelizeWorksheet) GetTables() ([]Table, error) { tables, err := w.file.GetTables(w.sheetName) if err != nil { return nil, fmt.Errorf("failed to get tables: %w", err) } tableList := make([]Table, len(tables)) for i, table := range tables { tableList[i] = Table{ Name: table.Name, Range: NormalizeRange(table.Range), } } return tableList, nil } func (w *ExcelizeWorksheet) GetPivotTables() ([]PivotTable, error) { pivotTables, err := w.file.GetPivotTables(w.sheetName) if err != nil { return nil, fmt.Errorf("failed to get pivot tables: %w", err) } pivotTableList := make([]PivotTable, len(pivotTables)) for i, pivotTable := range pivotTables { pivotTableList[i] = PivotTable{ Name: pivotTable.Name, Range: NormalizeRange(pivotTable.PivotTableRange), } } return pivotTableList, nil } func (w *ExcelizeWorksheet) SetValue(cell string, value any) error { if err := w.file.SetCellValue(w.sheetName, cell, value); err != nil { return err } if err := w.updateDimension(cell); err != nil { return fmt.Errorf("failed to update dimension: %w", err) } return nil } func (w *ExcelizeWorksheet) SetFormula(cell string, formula string) error { if err := w.file.SetCellFormula(w.sheetName, cell, formula); err != nil { return err } if err := w.updateDimension(cell); err != nil { return fmt.Errorf("failed to update dimension: %w", err) } return nil } func (w *ExcelizeWorksheet) GetValue(cell string) (string, error) { value, err := w.file.GetCellValue(w.sheetName, cell) if err != nil { return "", err } if value == "" { // try to get calculated value formula, err := w.file.GetCellFormula(w.sheetName, cell) if err != nil { return "", fmt.Errorf("failed to get formula: %w", err) } if formula != "" { return w.file.CalcCellValue(w.sheetName, cell) } } return value, nil } func (w *ExcelizeWorksheet) GetFormula(cell string) (string, error) { formula, err := w.file.GetCellFormula(w.sheetName, cell) if err != nil { return "", fmt.Errorf("failed to get formula: %w", err) } if formula == "" { // fallback return w.GetValue(cell) } if !strings.HasPrefix(formula, "=") { formula = "=" + formula } return formula, nil } func (w *ExcelizeWorksheet) GetDimention() (string, error) { return w.file.GetSheetDimension(w.sheetName) } func (w *ExcelizeWorksheet) GetPagingStrategy(pageSize int) (PagingStrategy, error) { return NewExcelizeFixedSizePagingStrategy(pageSize, w) } func (w *ExcelizeWorksheet) CapturePicture(captureRange string) (string, error) { return "", fmt.Errorf("CapturePicture is not supported in Excelize") } func (w *ExcelizeWorksheet) AddTable(tableRange, tableName string) error { enable := true if err := w.file.AddTable(w.sheetName, &excelize.Table{ Range: tableRange, Name: tableName, StyleName: "TableStyleMedium2", ShowColumnStripes: true, ShowFirstColumn: false, ShowHeaderRow: &enable, ShowLastColumn: false, ShowRowStripes: &enable, }); err != nil { return err } return nil } func (w *ExcelizeWorksheet) GetCellStyle(cell string) (*CellStyle, error) { styleID, err := w.file.GetCellStyle(w.sheetName, cell) if err != nil { return nil, fmt.Errorf("failed to get cell style: %w", err) } style, err := w.file.GetStyle(styleID) if err != nil { return nil, fmt.Errorf("failed to get style details: %w", err) } return convertExcelizeStyleToCellStyle(style), nil } func (w *ExcelizeWorksheet) SetCellStyle(cell string, style *CellStyle) error { excelizeStyle := convertCellStyleToExcelizeStyle(style) styleID, err := w.file.NewStyle(excelizeStyle) if err != nil { return fmt.Errorf("failed to create style: %w", err) } if err := w.file.SetCellStyle(w.sheetName, cell, cell, styleID); err != nil { return fmt.Errorf("failed to set cell style: %w", err) } return nil } func convertCellStyleToExcelizeStyle(style *CellStyle) *excelize.Style { result := &excelize.Style{} // Border if len(style.Border) > 0 { borders := make([]excelize.Border, len(style.Border)) for i, border := range style.Border { excelizeBorder := excelize.Border{ Type: border.Type.String(), } if border.Color != "" { excelizeBorder.Color = strings.TrimPrefix(border.Color, "#") } excelizeBorder.Style = borderStyleNameToInt(border.Style) borders[i] = excelizeBorder } result.Border = borders } // Font if style.Font != nil { font := &excelize.Font{} if style.Font.Bold != nil { font.Bold = *style.Font.Bold } if style.Font.Italic != nil { font.Italic = *style.Font.Italic } if style.Font.Underline != nil { font.Underline = style.Font.Underline.String() } if style.Font.Size != nil && *style.Font.Size > 0 { font.Size = float64(*style.Font.Size) } if style.Font.Strike != nil { font.Strike = *style.Font.Strike } if style.Font.Color != nil && *style.Font.Color != "" { font.Color = strings.TrimPrefix(*style.Font.Color, "#") } if style.Font.VertAlign != nil { font.VertAlign = style.Font.VertAlign.String() } result.Font = font } // Fill if style.Fill != nil { fill := excelize.Fill{} if style.Fill.Type != "" { fill.Type = style.Fill.Type.String() } fill.Pattern = fillPatternNameToInt(style.Fill.Pattern) if len(style.Fill.Color) > 0 { colors := make([]string, len(style.Fill.Color)) for i, color := range style.Fill.Color { colors[i] = strings.TrimPrefix(color, "#") } fill.Color = colors } if style.Fill.Shading != nil { fill.Shading = fillShadingNameToInt(*style.Fill.Shading) } result.Fill = fill } // NumFmt if style.NumFmt != nil && *style.NumFmt != "" { result.CustomNumFmt = style.NumFmt } // DecimalPlaces if style.DecimalPlaces != nil && *style.DecimalPlaces > 0 { result.DecimalPlaces = style.DecimalPlaces } return result } func convertExcelizeStyleToCellStyle(style *excelize.Style) *CellStyle { result := &CellStyle{} // Border if len(style.Border) > 0 { var borders []Border for _, border := range style.Border { borderStyle := Border{ Type: BorderType(border.Type), } if border.Color != "" { borderStyle.Color = "#" + strings.ToUpper(border.Color) } if border.Style != 0 { borderStyle.Style = intToBorderStyleName(border.Style) } borders = append(borders, borderStyle) } if len(borders) > 0 { result.Border = borders } } // Font if style.Font != nil { font := &FontStyle{} if style.Font.Bold { font.Bold = &style.Font.Bold } if style.Font.Italic { font.Italic = &style.Font.Italic } if style.Font.Underline != "" { underline := FontUnderline(style.Font.Underline) font.Underline = &underline } if style.Font.Size > 0 { size := int(style.Font.Size) font.Size = &size } if style.Font.Strike { font.Strike = &style.Font.Strike } if style.Font.Color != "" { color := "#" + strings.ToUpper(style.Font.Color) font.Color = &color } if style.Font.VertAlign != "" { vertAlign := FontVertAlign(style.Font.VertAlign) font.VertAlign = &vertAlign } if font.Bold != nil || font.Italic != nil || font.Underline != nil || font.Size != nil || font.Strike != nil || font.Color != nil || font.VertAlign != nil { result.Font = font } } // Fill if style.Fill.Type != "" || style.Fill.Pattern != 0 || len(style.Fill.Color) > 0 { fill := &FillStyle{} if style.Fill.Type != "" { fill.Type = FillType(style.Fill.Type) } if style.Fill.Pattern != 0 { fill.Pattern = intToFillPatternName(style.Fill.Pattern) } if len(style.Fill.Color) > 0 { var colors []string for _, color := range style.Fill.Color { if color != "" { colors = append(colors, "#"+strings.ToUpper(color)) } } if len(colors) > 0 { fill.Color = colors } } if style.Fill.Shading != 0 { shading := intToFillShadingName(style.Fill.Shading) fill.Shading = &shading } if fill.Type != "" || fill.Pattern != FillPatternNone || len(fill.Color) > 0 || fill.Shading != nil { result.Fill = fill } } // NumFmt if style.CustomNumFmt != nil && *style.CustomNumFmt != "" { result.NumFmt = style.CustomNumFmt } // DecimalPlaces if style.DecimalPlaces != nil && *style.DecimalPlaces != 0 { result.DecimalPlaces = style.DecimalPlaces } return result } func intToBorderStyleName(style int) BorderStyle { styles := map[int]BorderStyle{ 0: BorderStyleNone, 1: BorderStyleContinuous, 2: BorderStyleContinuous, 3: BorderStyleDash, 4: BorderStyleDot, 5: BorderStyleContinuous, 6: BorderStyleDouble, 7: BorderStyleContinuous, 8: BorderStyleDashDot, 9: BorderStyleDashDotDot, 10: BorderStyleSlantDashDot, 11: BorderStyleContinuous, 12: BorderStyleMediumDashDot, 13: BorderStyleMediumDashDotDot, } if name, exists := styles[style]; exists { return name } return BorderStyleContinuous } func intToFillPatternName(pattern int) FillPattern { patterns := map[int]FillPattern{ 0: FillPatternNone, 1: FillPatternSolid, 2: FillPatternMediumGray, 3: FillPatternDarkGray, 4: FillPatternLightGray, 5: FillPatternDarkHorizontal, 6: FillPatternDarkVertical, 7: FillPatternDarkDown, 8: FillPatternDarkUp, 9: FillPatternDarkGrid, 10: FillPatternDarkTrellis, 11: FillPatternLightHorizontal, 12: FillPatternLightVertical, 13: FillPatternLightDown, 14: FillPatternLightUp, 15: FillPatternLightGrid, 16: FillPatternLightTrellis, 17: FillPatternGray125, 18: FillPatternGray0625, } if name, exists := patterns[pattern]; exists { return name } return FillPatternNone } func intToFillShadingName(shading int) FillShading { shadings := map[int]FillShading{ 0: FillShadingHorizontal, 1: FillShadingVertical, 2: FillShadingDiagonalDown, 3: FillShadingDiagonalUp, 4: FillShadingFromCenter, 5: FillShadingFromCorner, } if name, exists := shadings[shading]; exists { return name } return FillShadingHorizontal } func borderStyleNameToInt(style BorderStyle) int { styles := map[BorderStyle]int{ BorderStyleNone: 0, BorderStyleContinuous: 1, BorderStyleDash: 3, BorderStyleDot: 4, BorderStyleDouble: 6, BorderStyleDashDot: 8, BorderStyleDashDotDot: 9, BorderStyleSlantDashDot: 10, BorderStyleMediumDashDot: 12, BorderStyleMediumDashDotDot: 13, } if value, exists := styles[style]; exists { return value } return 1 } func fillPatternNameToInt(pattern FillPattern) int { patterns := map[FillPattern]int{ FillPatternNone: 0, FillPatternSolid: 1, FillPatternMediumGray: 2, FillPatternDarkGray: 3, FillPatternLightGray: 4, FillPatternDarkHorizontal: 5, FillPatternDarkVertical: 6, FillPatternDarkDown: 7, FillPatternDarkUp: 8, FillPatternDarkGrid: 9, FillPatternDarkTrellis: 10, FillPatternLightHorizontal: 11, FillPatternLightVertical: 12, FillPatternLightDown: 13, FillPatternLightUp: 14, FillPatternLightGrid: 15, FillPatternLightTrellis: 16, FillPatternGray125: 17, FillPatternGray0625: 18, } if value, exists := patterns[pattern]; exists { return value } return 0 } func fillShadingNameToInt(shading FillShading) int { shadings := map[FillShading]int{ FillShadingHorizontal: 0, FillShadingVertical: 1, FillShadingDiagonalDown: 2, FillShadingDiagonalUp: 3, FillShadingFromCenter: 4, FillShadingFromCorner: 5, } if value, exists := shadings[shading]; exists { return value } return 0 } // updateDimention updates the dimension of the worksheet after a cell is updated. func (w *ExcelizeWorksheet) updateDimension(updatedCell string) error { dimension, err := w.file.GetSheetDimension(w.sheetName) if err != nil { return err } startCol, startRow, endCol, endRow, err := ParseRange(dimension) if err != nil { return err } updatedCol, updatedRow, err := excelize.CellNameToCoordinates(updatedCell) if err != nil { return err } if startCol > updatedCol { startCol = updatedCol } if endCol < updatedCol { endCol = updatedCol } if startRow > updatedRow { startRow = updatedRow } if endRow < updatedRow { endRow = updatedRow } startRange, err := excelize.CoordinatesToCellName(startCol, startRow) if err != nil { return err } endRange, err := excelize.CoordinatesToCellName(endCol, endRow) if err != nil { return err } updatedDimension := fmt.Sprintf("%s:%s", startRange, endRange) return w.file.SetSheetDimension(w.sheetName, updatedDimension) } ``` -------------------------------------------------------------------------------- /internal/excel/excel_ole.go: -------------------------------------------------------------------------------- ```go package excel import ( "bufio" "bytes" "encoding/base64" "fmt" "io" "path/filepath" "regexp" "runtime" "strings" "github.com/go-ole/go-ole" "github.com/go-ole/go-ole/oleutil" "github.com/skanehira/clipboard-image" ) type OleExcel struct { application *ole.IDispatch workbook *ole.IDispatch } type OleWorksheet struct { excel *OleExcel worksheet *ole.IDispatch } func NewExcelOle(absolutePath string) (*OleExcel, func(), error) { runtime.LockOSThread() ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) unknown, err := oleutil.GetActiveObject("Excel.Application") if err != nil { return nil, func() {}, err } excel, err := unknown.QueryInterface(ole.IID_IDispatch) if err != nil { return nil, func() {}, err } oleutil.MustPutProperty(excel, "ScreenUpdating", false) oleutil.MustPutProperty(excel, "EnableEvents", false) workbooks := oleutil.MustGetProperty(excel, "Workbooks").ToIDispatch() c := oleutil.MustGetProperty(workbooks, "Count").Val for i := 1; i <= int(c); i++ { workbook := oleutil.MustGetProperty(workbooks, "Item", i).ToIDispatch() fullName := oleutil.MustGetProperty(workbook, "FullName").ToString() name := oleutil.MustGetProperty(workbook, "Name").ToString() if strings.HasPrefix(fullName, "https:") && name == filepath.Base(absolutePath) { // If a workbook is opened through a WOPI URL, its absolute file path cannot be retrieved. // If the absolutePath is not writable, it assumes that the workbook has opened by WOPI. if FileIsNotWritable(absolutePath) { return &OleExcel{application: excel, workbook: workbook}, func() { oleutil.MustPutProperty(excel, "EnableEvents", true) oleutil.MustPutProperty(excel, "ScreenUpdating", true) workbook.Release() workbooks.Release() excel.Release() ole.CoUninitialize() runtime.UnlockOSThread() }, nil } else { // This workbook might not be specified with the absolutePath } } else if normalizePath(fullName) == normalizePath(absolutePath) { return &OleExcel{application: excel, workbook: workbook}, func() { oleutil.MustPutProperty(excel, "EnableEvents", true) oleutil.MustPutProperty(excel, "ScreenUpdating", true) workbook.Release() workbooks.Release() excel.Release() ole.CoUninitialize() runtime.UnlockOSThread() }, nil } } return nil, func() {}, fmt.Errorf("workbook not found: %s", absolutePath) } func NewExcelOleWithNewObject(absolutePath string) (*OleExcel, func(), error) { runtime.LockOSThread() ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) unknown, err := oleutil.CreateObject("Excel.Application") if err != nil { return nil, func() {}, err } excel, err := unknown.QueryInterface(ole.IID_IDispatch) if err != nil { return nil, func() {}, err } workbooks := oleutil.MustGetProperty(excel, "Workbooks").ToIDispatch() workbook, err := oleutil.CallMethod(workbooks, "Open", absolutePath) if err != nil { return nil, func() {}, err } w := workbook.ToIDispatch() return &OleExcel{application: excel, workbook: w}, func() { w.Release() workbooks.Release() excel.Release() oleutil.CallMethod(excel, "Close") ole.CoUninitialize() runtime.UnlockOSThread() }, nil } func (o *OleExcel) GetBackendName() string { return "ole" } func (o *OleExcel) GetSheets() ([]Worksheet, error) { worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() defer worksheets.Release() count := int(oleutil.MustGetProperty(worksheets, "Count").Val) worksheetList := make([]Worksheet, count) for i := 1; i <= count; i++ { worksheet := oleutil.MustGetProperty(worksheets, "Item", i).ToIDispatch() worksheetList[i-1] = &OleWorksheet{ excel: o, worksheet: worksheet, } } return worksheetList, nil } func (o *OleExcel) FindSheet(sheetName string) (Worksheet, error) { worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() defer worksheets.Release() count := int(oleutil.MustGetProperty(worksheets, "Count").Val) for i := 1; i <= count; i++ { worksheet := oleutil.MustGetProperty(worksheets, "Item", i).ToIDispatch() name := oleutil.MustGetProperty(worksheet, "Name").ToString() if name == sheetName { return &OleWorksheet{ excel: o, worksheet: worksheet, }, nil } } return nil, fmt.Errorf("sheet not found: %s", sheetName) } func (o *OleExcel) CreateNewSheet(sheetName string) error { activeWorksheet := oleutil.MustGetProperty(o.workbook, "ActiveSheet").ToIDispatch() defer activeWorksheet.Release() activeWorksheetIndex := oleutil.MustGetProperty(activeWorksheet, "Index").Val worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() defer worksheets.Release() _, err := oleutil.CallMethod(worksheets, "Add", nil, activeWorksheet) if err != nil { return err } worksheet := oleutil.MustGetProperty(worksheets, "Item", activeWorksheetIndex+1).ToIDispatch() defer worksheet.Release() _, err = oleutil.PutProperty(worksheet, "Name", sheetName) if err != nil { return err } return nil } func (o *OleExcel) CopySheet(srcSheetName string, dstSheetName string) error { worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() defer worksheets.Release() srcSheetVariant, err := oleutil.GetProperty(worksheets, "Item", srcSheetName) if err != nil { return fmt.Errorf("faild to get sheet: %w", err) } srcSheet := srcSheetVariant.ToIDispatch() defer srcSheet.Release() srcSheetIndex := oleutil.MustGetProperty(srcSheet, "Index").Val _, err = oleutil.CallMethod(srcSheet, "Copy", nil, srcSheet) if err != nil { return err } dstSheetVariant, err := oleutil.GetProperty(worksheets, "Item", srcSheetIndex+1) if err != nil { return fmt.Errorf("failed to get copied sheet: %w", err) } dstSheet := dstSheetVariant.ToIDispatch() defer dstSheet.Release() _, err = oleutil.PutProperty(dstSheet, "Name", dstSheetName) if err != nil { return err } return nil } func (o *OleExcel) Save() error { _, err := oleutil.CallMethod(o.workbook, "Save") if err != nil { return err } return nil } func (o *OleWorksheet) Release() { o.worksheet.Release() } func (o *OleWorksheet) Name() (string, error) { name := oleutil.MustGetProperty(o.worksheet, "Name").ToString() return name, nil } func (o *OleWorksheet) GetTables() ([]Table, error) { tables := oleutil.MustGetProperty(o.worksheet, "ListObjects").ToIDispatch() defer tables.Release() count := int(oleutil.MustGetProperty(tables, "Count").Val) tableList := make([]Table, count) for i := 1; i <= count; i++ { table := oleutil.MustGetProperty(tables, "Item", i).ToIDispatch() defer table.Release() name := oleutil.MustGetProperty(table, "Name").ToString() defer table.Release() tableRange := oleutil.MustGetProperty(table, "Range").ToIDispatch() defer tableRange.Release() tableList[i-1] = Table{ Name: name, Range: NormalizeRange(oleutil.MustGetProperty(tableRange, "Address").ToString()), } } return tableList, nil } func (o *OleWorksheet) GetPivotTables() ([]PivotTable, error) { pivotTables := oleutil.MustGetProperty(o.worksheet, "PivotTables").ToIDispatch() defer pivotTables.Release() count := int(oleutil.MustGetProperty(pivotTables, "Count").Val) pivotTableList := make([]PivotTable, count) for i := 1; i <= count; i++ { pivotTable := oleutil.MustGetProperty(pivotTables, "Item", i).ToIDispatch() defer pivotTable.Release() name := oleutil.MustGetProperty(pivotTable, "Name").ToString() pivotTableRange := oleutil.MustGetProperty(pivotTable, "TableRange1").ToIDispatch() defer pivotTableRange.Release() pivotTableList[i-1] = PivotTable{ Name: name, Range: NormalizeRange(oleutil.MustGetProperty(pivotTableRange, "Address").ToString()), } } return pivotTableList, nil } func (o *OleWorksheet) SetValue(cell string, value any) error { range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer range_.Release() _, err := oleutil.PutProperty(range_, "Value", value) return err } func (o *OleWorksheet) SetFormula(cell string, formula string) error { range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer range_.Release() _, err := oleutil.PutProperty(range_, "Formula", formula) return err } func (o *OleWorksheet) GetValue(cell string) (string, error) { range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer range_.Release() value := oleutil.MustGetProperty(range_, "Text").Value() switch v := value.(type) { case string: return v, nil case nil: return "", nil default: // Handle other types as needed return "", fmt.Errorf("unsupported type: %T", v) } } func (o *OleWorksheet) GetFormula(cell string) (string, error) { range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer range_.Release() formula := oleutil.MustGetProperty(range_, "Formula").ToString() return formula, nil } func (o *OleWorksheet) GetDimention() (string, error) { range_ := oleutil.MustGetProperty(o.worksheet, "UsedRange").ToIDispatch() defer range_.Release() dimension := oleutil.MustGetProperty(range_, "Address").ToString() return NormalizeRange(dimension), nil } func (o *OleWorksheet) GetPagingStrategy(pageSize int) (PagingStrategy, error) { return NewOlePagingStrategy(1000, o) } func (o *OleWorksheet) PrintArea() (string, error) { v, err := oleutil.GetProperty(o.worksheet, "PageSetup") if err != nil { return "", err } pageSetup := v.ToIDispatch() defer pageSetup.Release() printArea := oleutil.MustGetProperty(pageSetup, "PrintArea").ToString() return printArea, nil } func (o *OleWorksheet) HPageBreaks() ([]int, error) { v, err := oleutil.GetProperty(o.worksheet, "HPageBreaks") if err != nil { return nil, err } hPageBreaks := v.ToIDispatch() defer hPageBreaks.Release() count := int(oleutil.MustGetProperty(hPageBreaks, "Count").Val) pageBreaks := make([]int, count) for i := 1; i <= count; i++ { pageBreak := oleutil.MustGetProperty(hPageBreaks, "Item", i).ToIDispatch() defer pageBreak.Release() location := oleutil.MustGetProperty(pageBreak, "Location").ToIDispatch() defer location.Release() row := oleutil.MustGetProperty(location, "Row").Val pageBreaks[i-1] = int(row) } return pageBreaks, nil } func (o *OleWorksheet) CapturePicture(captureRange string) (string, error) { r := oleutil.MustGetProperty(o.worksheet, "Range", captureRange).ToIDispatch() defer r.Release() _, err := oleutil.CallMethod( r, "CopyPicture", int(1), // xlScreen (https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.excel.xlpictureappearance?view=excel-pia) int(2), // xlBitmap (https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.excel.xlcopypictureformat?view=excel-pia) ) if err != nil { return "", err } // Read the image from the clipboard buf := new(bytes.Buffer) bufWriter := bufio.NewWriter(buf) clipboardReader, err := clipboard.ReadFromClipboard() if err != nil { return "", fmt.Errorf("failed to read from clipboard: %w", err) } if _, err := io.Copy(bufWriter, clipboardReader); err != nil { return "", fmt.Errorf("failed to copy clipboard data: %w", err) } if err := bufWriter.Flush(); err != nil { return "", fmt.Errorf("failed to flush buffer: %w", err) } return base64.StdEncoding.EncodeToString(buf.Bytes()), nil } func (o *OleWorksheet) AddTable(tableRange string, tableName string) error { tables := oleutil.MustGetProperty(o.worksheet, "ListObjects").ToIDispatch() defer tables.Release() // https://learn.microsoft.com/ja-jp/office/vba/api/excel.listobjects.add tableVar, err := oleutil.CallMethod( tables, "Add", int(1), // xlSrcRange (https://learn.microsoft.com/ja-jp/office/vba/api/excel.xllistobjectsourcetype) tableRange, nil, int(0), // xlYes (https://learn.microsoft.com/ja-jp/office/vba/api/excel.xlyesnoguess) ) if err != nil { return err } table := tableVar.ToIDispatch() defer table.Release() _, err = oleutil.PutProperty(table, "Name", tableName) if err != nil { return err } return err } func (o *OleWorksheet) GetCellStyle(cell string) (*CellStyle, error) { rng := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer rng.Release() style := &CellStyle{} // Get Font information normalStyle := oleutil.MustGetProperty(o.excel.workbook, "Styles", "Normal").ToIDispatch() defer normalStyle.Release() normalFont := oleutil.MustGetProperty(normalStyle, "Font").ToIDispatch() defer normalFont.Release() font := oleutil.MustGetProperty(rng, "Font").ToIDispatch() defer font.Release() normalFontSize := int(oleutil.MustGetProperty(normalFont, "Size").Value().(float64)) normalFontBold := oleutil.MustGetProperty(normalFont, "Bold").Value().(bool) normalFontItalic := oleutil.MustGetProperty(normalFont, "Italic").Value().(bool) normalFontColor := oleutil.MustGetProperty(normalFont, "Color").Value().(float64) fontSize := int(oleutil.MustGetProperty(font, "Size").Value().(float64)) fontBold := oleutil.MustGetProperty(font, "Bold").Value().(bool) fontItalic := oleutil.MustGetProperty(font, "Italic").Value().(bool) fontColor := oleutil.MustGetProperty(font, "Color").Value().(float64) if fontSize != normalFontSize || fontBold != normalFontBold || fontItalic != normalFontItalic || fontColor != normalFontColor { colorStr := bgrToRgb(fontColor) style.Font = &FontStyle{ Bold: &fontBold, Italic: &fontItalic, Size: &fontSize, Color: &colorStr, } } // Get Interior (fill) information interior := oleutil.MustGetProperty(rng, "Interior").ToIDispatch() defer interior.Release() interiorPattern := excelPatternToFillPattern(oleutil.MustGetProperty(interior, "Pattern").Value().(int32)) if interiorPattern != FillPatternNone { interiorColor := oleutil.MustGetProperty(interior, "Color").Value().(float64) style.Fill = &FillStyle{ Type: "pattern", Pattern: interiorPattern, Color: []string{bgrToRgb(interiorColor)}, } } // Get Border information var borderStyles []Border // Get borders for each direction: Left(7), Top(8), Bottom(9), Right(10) borderPositions := []struct { index int position BorderType }{ {7, BorderTypeLeft}, {8, BorderTypeTop}, {9, BorderTypeBottom}, {10, BorderTypeRight}, } borders := oleutil.MustGetProperty(rng, "Borders").ToIDispatch() defer borders.Release() bordersLineStyle := oleutil.MustGetProperty(borders, "LineStyle") if bordersLineStyle.VT == ole.VT_NULL { // If Borders.LineStyle is null, the borders have different styles for _, pos := range borderPositions { border := oleutil.MustGetProperty(borders, "Item", pos.index).ToIDispatch() defer border.Release() borderLineStyle := excelBorderStyleToName(oleutil.MustGetProperty(border, "LineStyle").Value().(int32)) if borderLineStyle != BorderStyleNone { borderColor := oleutil.MustGetProperty(border, "Color").Value().(float64) borderStyle := Border{ Type: pos.position, Style: borderLineStyle, Color: bgrToRgb(borderColor), } borderStyles = append(borderStyles, borderStyle) } } } else { // If Borders.LineStyle is not null, all borders have the same style lineStyle := excelBorderStyleToName(bordersLineStyle.Value().(int32)) if lineStyle != BorderStyleNone { for _, pos := range borderPositions { border := oleutil.MustGetProperty(borders, "Item", pos.index).ToIDispatch() borderColor := oleutil.MustGetProperty(border, "Color").Value().(float64) borderStyle := Border{ Type: pos.position, Style: lineStyle, Color: bgrToRgb(borderColor), } borderStyles = append(borderStyles, borderStyle) } } } style.Border = borderStyles // Get NumberFormat information generalNumberFormat := oleutil.MustGetProperty(o.excel.application, "International", 26).Value().(string) // xlGeneralFormatName numberFormat := oleutil.MustGetProperty(rng, "NumberFormat").ToString() if numberFormat != generalNumberFormat && numberFormat != "@" { style.NumFmt = &numberFormat } // Extract decimal places from number format if it's a numeric format decimalPlaces := extractDecimalPlacesFromFormat(numberFormat) style.DecimalPlaces = &decimalPlaces return style, nil } // bgrToRgb converts BGR color format to RGB hex string func bgrToRgb(bgrColor float64) string { bgrColorInt := int32(bgrColor) // Extract RGB components from BGR format r := (bgrColorInt >> 0) & 0xFF g := (bgrColorInt >> 8) & 0xFF b := (bgrColorInt >> 16) & 0xFF return fmt.Sprintf("#%02X%02X%02X", r, g, b) } // excelBorderStyleToName converts Excel border style constant to BorderStyleName func excelBorderStyleToName(excelStyle int32) BorderStyle { switch excelStyle { case 1: // xlContinuous return BorderStyleContinuous case -4115: // xlDash return BorderStyleDash case -4118: // xlDot return BorderStyleDot case -4119: // xlDouble return BorderStyleDouble case 4: // xlDashDot return BorderStyleDashDot case 5: // xlDashDotDot return BorderStyleDashDotDot case 13: // xlSlantDashDot return BorderStyleSlantDashDot case -4142: // xlLineStyleNone return BorderStyleNone default: return BorderStyleNone } } // excelPatternToFillPattern converts Excel XlPattern constant to FillPatternName func excelPatternToFillPattern(excelPattern int32) FillPattern { switch excelPattern { case -4142: // xlPatternNone return FillPatternNone case 1: // xlPatternSolid return FillPatternSolid case -4125: // xlPatternGray75 return FillPatternDarkGray case -4124: // xlPatternGray50 return FillPatternMediumGray case -4126: // xlPatternGray25 return FillPatternLightGray case -4121: // xlPatternGray16 return FillPatternGray125 case -4127: // xlPatternGray8 return FillPatternGray0625 case 9: // xlPatternHorizontal return FillPatternLightHorizontal case 12: // xlPatternVertical return FillPatternLightVertical case 10: // xlPatternDown return FillPatternLightDown case 11: // xlPatternUp return FillPatternLightUp case 16: // xlPatternGrid return FillPatternLightGrid case 17: // xlPatternCrissCross return FillPatternLightTrellis case 5: // xlPatternLightHorizontal return FillPatternLightHorizontal case 6: // xlPatternLightVertical return FillPatternLightVertical case 7: // xlPatternLightDown return FillPatternLightDown case 8: // xlPatternLightUp return FillPatternLightUp case 15: // xlPatternLightGrid return FillPatternLightGrid case 18: // xlPatternLightTrellis return FillPatternLightTrellis case 13: // xlPatternSemiGray75 return FillPatternDarkHorizontal case 2: // xlPatternDarkHorizontal return FillPatternDarkHorizontal case 3: // xlPatternDarkVertical return FillPatternDarkVertical case 4: // xlPatternDarkDown return FillPatternDarkDown case 14: // xlPatternDarkUp return FillPatternDarkUp case -4162: // xlPatternDarkGrid return FillPatternDarkGrid case -4166: // xlPatternDarkTrellis return FillPatternDarkTrellis default: return FillPatternNone } } var extractDecimalPlacesRegexp = regexp.MustCompile(`\.([0#]+)`) // extractDecimalPlacesFromFormat extracts decimal places count from Excel number format string func extractDecimalPlacesFromFormat(format string) int { // Handle common numeric formats // Examples: "0.00" -> 2, "#,##0.000" -> 3, "0" -> 0 matches := extractDecimalPlacesRegexp.FindStringSubmatch(format) if len(matches) > 1 { return len(matches[1]) } return 0 } func (o *OleWorksheet) SetCellStyle(cell string, style *CellStyle) error { rng := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() defer rng.Release() // Apply Font styles if style.Font != nil { font := oleutil.MustGetProperty(rng, "Font").ToIDispatch() defer font.Release() if style.Font.Bold != nil { oleutil.PutProperty(font, "Bold", *style.Font.Bold) } if style.Font.Italic != nil { oleutil.PutProperty(font, "Italic", *style.Font.Italic) } if style.Font.Size != nil && *style.Font.Size > 0 { oleutil.PutProperty(font, "Size", *style.Font.Size) } if style.Font.Color != nil && *style.Font.Color != "" { colorValue := rgbToBgr(*style.Font.Color) oleutil.PutProperty(font, "Color", colorValue) } if style.Font.Strike != nil && *style.Font.Strike { oleutil.PutProperty(font, "Strikethrough", true) } } // Apply Fill styles if style.Fill != nil { interior := oleutil.MustGetProperty(rng, "Interior").ToIDispatch() defer interior.Release() if style.Fill.Pattern != FillPatternNone { oleutil.PutProperty(interior, "Pattern", fillPatternToExcelPattern(style.Fill.Pattern)) } if len(style.Fill.Color) > 0 && style.Fill.Color[0] != "" { colorValue := rgbToBgr(style.Fill.Color[0]) oleutil.PutProperty(interior, "Color", colorValue) } } // Apply Border styles if len(style.Border) > 0 { borders := oleutil.MustGetProperty(rng, "Borders").ToIDispatch() defer borders.Release() for _, borderStyle := range style.Border { borderIndex := borderTypeToIndex(borderStyle.Type) if borderIndex > 0 { border := oleutil.MustGetProperty(borders, "Item", borderIndex).ToIDispatch() defer border.Release() oleutil.PutProperty(border, "LineStyle", borderStyleNameToExcel(borderStyle.Style)) if borderStyle.Color != "" { colorValue := rgbToBgr(borderStyle.Color) oleutil.PutProperty(border, "Color", colorValue) } } } } // Apply Number Format if style.NumFmt != nil && *style.NumFmt != "" { oleutil.PutProperty(rng, "NumberFormat", *style.NumFmt) } return nil } // rgbToBgr converts RGB hex string to BGR color format func rgbToBgr(rgbColor string) int32 { if len(rgbColor) != 7 || rgbColor[0] != '#' { return 0 } r := hexToByte(rgbColor[1:3]) g := hexToByte(rgbColor[3:5]) b := hexToByte(rgbColor[5:7]) return int32(r) | (int32(g) << 8) | (int32(b) << 16) } // hexToByte converts hex string to byte func hexToByte(hex string) byte { var result byte for _, char := range hex { result *= 16 if char >= '0' && char <= '9' { result += byte(char - '0') } else if char >= 'A' && char <= 'F' { result += byte(char - 'A' + 10) } else if char >= 'a' && char <= 'f' { result += byte(char - 'a' + 10) } } return result } // borderTypeToIndex converts border type string to Excel border index func borderTypeToIndex(borderType BorderType) int { switch borderType { case BorderTypeLeft: return 7 case BorderTypeTop: return 8 case BorderTypeBottom: return 9 case BorderTypeRight: return 10 case BorderTypeDiagonalDown: return 5 case BorderTypeDiagonalUp: return 6 default: return 0 } } // borderStyleNameToExcel converts BorderStyleName to Excel constant func borderStyleNameToExcel(style BorderStyle) int32 { switch style { case BorderStyleContinuous: return 1 // xlContinuous case BorderStyleDash: return -4115 // xlDash case BorderStyleDot: return -4118 // xlDot case BorderStyleDouble: return -4119 // xlDouble case BorderStyleDashDot: return 4 // xlDashDot case BorderStyleDashDotDot: return 5 // xlDashDotDot case BorderStyleSlantDashDot: return 13 // xlSlantDashDot case BorderStyleNone: return -4142 // xlLineStyleNone default: return -4142 // xlLineStyleNone } } // fillPatternToExcelPattern converts FillPatternName to Excel pattern constant func fillPatternToExcelPattern(pattern FillPattern) int32 { switch pattern { case FillPatternSolid: return 1 // xlPatternSolid case FillPatternMediumGray: return -4124 // xlPatternGray50 case FillPatternDarkGray: return -4125 // xlPatternGray75 case FillPatternLightGray: return -4126 // xlPatternGray25 case FillPatternGray125: return -4121 // xlPatternGray16 case FillPatternGray0625: return -4127 // xlPatternGray8 case FillPatternLightHorizontal: return 5 // xlPatternLightHorizontal case FillPatternLightVertical: return 6 // xlPatternLightVertical case FillPatternLightDown: return 7 // xlPatternLightDown case FillPatternLightUp: return 8 // xlPatternLightUp case FillPatternLightGrid: return 15 // xlPatternLightGrid case FillPatternLightTrellis: return 18 // xlPatternLightTrellis case FillPatternDarkHorizontal: return 2 // xlPatternDarkHorizontal case FillPatternDarkVertical: return 3 // xlPatternDarkVertical case FillPatternDarkDown: return 4 // xlPatternDarkDown case FillPatternDarkUp: return 14 // xlPatternDarkUp case FillPatternDarkGrid: return -4162 // xlPatternDarkGrid case FillPatternDarkTrellis: return -4166 // xlPatternDarkTrellis case FillPatternNone: return -4142 // xlPatternNone default: return -4142 // xlPatternNone } } func normalizePath(path string) string { // Normalize the volume name to uppercase vol := filepath.VolumeName(path) if vol == "" { return path } rest := path[len(vol):] return filepath.Clean(strings.ToUpper(vol) + rest) } ```