# 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: -------------------------------------------------------------------------------- ``` 1 | /dist 2 | /node_modules 3 | ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` 1 | /.github 2 | /cmd 3 | /internal 4 | /go.mod 5 | /go.sum 6 | /.goreleaser.yaml 7 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | /node_modules 2 | /dist 3 | /memory-bank 4 | # Added by goreleaser init: 5 | dist/ 6 | # RooFlow 7 | .roo 8 | .roomodes 9 | .clinerules-default 10 | # repomix 11 | repomix-output.xml 12 | # Claude Code 13 | CLAUDE.local.md 14 | .claude/settings.local.json 15 | ``` -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- ``` 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 10 | end_of_line = lf 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | indent_size = 4 17 | ``` -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- ```yaml 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | # You may remove this if you don't use go modules. 6 | - go mod tidy 7 | # you may remove this if you don't need go generate 8 | - go generate ./... 9 | 10 | builds: 11 | - env: 12 | - CGO_ENABLED=0 13 | main: ./cmd/excel-mcp-server/main.go 14 | goos: 15 | - linux 16 | - windows 17 | - darwin 18 | 19 | archives: 20 | - formats: ['tar.gz'] 21 | # this name template makes the OS and Arch compatible with the results of `uname`. 22 | name_template: >- 23 | {{ .ProjectName }}_ 24 | {{- title .Os }}_ 25 | {{- if eq .Arch "amd64" }}x86_64 26 | {{- else if eq .Arch "386" }}i386 27 | {{- else }}{{ .Arch }}{{ end }} 28 | {{- if .Arm }}v{{ .Arm }}{{ end }} 29 | # use zip for windows archives 30 | format_overrides: 31 | - goos: windows 32 | formats: ['zip'] 33 | builds_info: 34 | mode: 0755 35 | 36 | changelog: 37 | sort: asc 38 | filters: 39 | exclude: 40 | - "^docs:" 41 | - "^test:" 42 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Excel MCP Server 2 | 3 | <img src="https://github.com/negokaz/excel-mcp-server/blob/main/docs/img/icon-800.png?raw=true" width="128"> 4 | 5 | <a href="https://glama.ai/mcp/servers/@negokaz/excel-mcp-server"> 6 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@negokaz/excel-mcp-server/badge" alt="Excel Server MCP server" /> 7 | </a> 8 | 9 | [](https://www.npmjs.com/package/@negokaz/excel-mcp-server) 10 | [](https://smithery.ai/server/@negokaz/excel-mcp-server) 11 | 12 | A Model Context Protocol (MCP) server that reads and writes MS Excel data. 13 | 14 | ## Features 15 | 16 | - Read/Write text values 17 | - Read/Write formulas 18 | - Create new sheets 19 | 20 | **🪟Windows only:** 21 | - Live editing 22 | - Capture screen image from a sheet 23 | 24 | For more details, see the [tools](#tools) section. 25 | 26 | ## Requirements 27 | 28 | - Node.js 20.x or later 29 | 30 | ## Supported file formats 31 | 32 | - xlsx (Excel book) 33 | - xlsm (Excel macro-enabled book) 34 | - xltx (Excel template) 35 | - xltm (Excel macro-enabled template) 36 | 37 | ## Installation 38 | 39 | ### Installing via NPM 40 | 41 | excel-mcp-server is automatically installed by adding the following configuration to the MCP servers configuration. 42 | 43 | For Windows: 44 | ```json 45 | { 46 | "mcpServers": { 47 | "excel": { 48 | "command": "cmd", 49 | "args": ["/c", "npx", "--yes", "@negokaz/excel-mcp-server"], 50 | "env": { 51 | "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000" 52 | } 53 | } 54 | } 55 | } 56 | ``` 57 | 58 | For other platforms: 59 | ```json 60 | { 61 | "mcpServers": { 62 | "excel": { 63 | "command": "npx", 64 | "args": ["--yes", "@negokaz/excel-mcp-server"], 65 | "env": { 66 | "EXCEL_MCP_PAGING_CELLS_LIMIT": "4000" 67 | } 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ### Installing via Smithery 74 | 75 | To install Excel MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@negokaz/excel-mcp-server): 76 | 77 | ```bash 78 | npx -y @smithery/cli install @negokaz/excel-mcp-server --client claude 79 | ``` 80 | 81 | <h2 id="tools">Tools</h2> 82 | 83 | ### `excel_describe_sheets` 84 | 85 | List all sheet information of specified Excel file. 86 | 87 | **Arguments:** 88 | - `fileAbsolutePath` 89 | - Absolute path to the Excel file 90 | 91 | ### `excel_read_sheet` 92 | 93 | Read values from Excel sheet with pagination. 94 | 95 | **Arguments:** 96 | - `fileAbsolutePath` 97 | - Absolute path to the Excel file 98 | - `sheetName` 99 | - Sheet name in the Excel file 100 | - `range` 101 | - Range of cells to read in the Excel sheet (e.g., "A1:C10"). [default: first paging range] 102 | - `showFormula` 103 | - Show formula instead of value [default: false] 104 | - `showStyle` 105 | - Show style information for cells [default: false] 106 | 107 | ### `excel_screen_capture` 108 | 109 | **[Windows only]** Take a screenshot of the Excel sheet with pagination. 110 | 111 | **Arguments:** 112 | - `fileAbsolutePath` 113 | - Absolute path to the Excel file 114 | - `sheetName` 115 | - Sheet name in the Excel file 116 | - `range` 117 | - Range of cells to read in the Excel sheet (e.g., "A1:C10"). [default: first paging range] 118 | 119 | ### `excel_write_to_sheet` 120 | 121 | Write values to the Excel sheet. 122 | 123 | **Arguments:** 124 | - `fileAbsolutePath` 125 | - Absolute path to the Excel file 126 | - `sheetName` 127 | - Sheet name in the Excel file 128 | - `newSheet` 129 | - Create a new sheet if true, otherwise write to the existing sheet 130 | - `range` 131 | - Range of cells to read in the Excel sheet (e.g., "A1:C10"). 132 | - `values` 133 | - Values to write to the Excel sheet. If the value is a formula, it should start with "=" 134 | 135 | ### `excel_create_table` 136 | 137 | Create a table in the Excel sheet 138 | 139 | **Arguments:** 140 | - `fileAbsolutePath` 141 | - Absolute path to the Excel file 142 | - `sheetName` 143 | - Sheet name where the table is created 144 | - `range` 145 | - Range to be a table (e.g., "A1:C10") 146 | - `tableName` 147 | - Table name to be created 148 | 149 | ### `excel_copy_sheet` 150 | 151 | Copy existing sheet to a new sheet 152 | 153 | **Arguments:** 154 | - `fileAbsolutePath` 155 | - Absolute path to the Excel file 156 | - `srcSheetName` 157 | - Source sheet name in the Excel file 158 | - `dstSheetName` 159 | - Sheet name to be copied 160 | 161 | ### `excel_format_range` 162 | 163 | Format cells in the Excel sheet with style information 164 | 165 | **Arguments:** 166 | - `fileAbsolutePath` 167 | - Absolute path to the Excel file 168 | - `sheetName` 169 | - Sheet name in the Excel file 170 | - `range` 171 | - Range of cells in the Excel sheet (e.g., "A1:C3") 172 | - `styles` 173 | - 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. 174 | - Style object properties: 175 | - `border`: Array of border styles (type, color, style) 176 | - `font`: Font styling (bold, italic, underline, size, strike, color, vertAlign) 177 | - `fill`: Fill/background styling (type, pattern, color, shading) 178 | - `numFmt`: Custom number format string 179 | - `decimalPlaces`: Number of decimal places (0-30) 180 | 181 | <h2 id="configuration">Configuration</h2> 182 | 183 | You can change the MCP Server behaviors by the following environment variables: 184 | 185 | ### `EXCEL_MCP_PAGING_CELLS_LIMIT` 186 | 187 | The maximum number of cells to read in a single paging operation. 188 | [default: 4000] 189 | 190 | ## License 191 | 192 | Copyright (c) 2025 Kazuki Negoro 193 | 194 | excel-mcp-server is released under the [MIT License](LICENSE) ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | 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. 8 | 9 | ## Development Commands 10 | 11 | ### Build and Development 12 | ```bash 13 | npm run build # Build Go binaries + compile TypeScript 14 | npm run watch # Watch TypeScript files for changes 15 | npm run debug # Debug with MCP inspector 16 | ``` 17 | 18 | ### Testing 19 | ```bash 20 | go test ./... # Run all Go tests 21 | go test ./internal/tools -v # Run specific package tests 22 | go test -run TestReadSheetData ./internal/tools # Run specific test 23 | ``` 24 | 25 | ### Linting and Formatting 26 | ```bash 27 | go fmt ./... # Format Go code 28 | go vet ./... # Vet Go code for issues 29 | ``` 30 | 31 | ## Architecture 32 | 33 | ### Core Components 34 | 35 | **Dual Backend Architecture**: The server supports two Excel backends: 36 | - **Windows**: OLE automation for live Excel interaction (`excel_ole.go`) 37 | - **Cross-platform**: Excelize library for file operations (`excel_excelize.go`) 38 | 39 | **Key Interfaces**: 40 | - `ExcelInterface` in `internal/excel/excel.go` - Unified Excel operations API 41 | - `Tool` interface in `internal/tools/` - MCP tool implementations 42 | 43 | **Entry Points**: 44 | - `cmd/excel-mcp-server/main.go` - Go binary entry point 45 | - `launcher/launcher.ts` - Cross-platform launcher that selects appropriate binary 46 | 47 | ### Tool System 48 | 49 | MCP tools are implemented in `internal/tools/`: 50 | - `excel_describe_sheets` - List worksheets and metadata 51 | - `excel_read_sheet` - Read sheet data with pagination 52 | - `excel_write_to_sheet` - Write data to sheets 53 | - `excel_create_table` - Create Excel tables 54 | - `excel_copy_sheet` - Copy sheets between workbooks 55 | - `excel_screen_capture` - Windows-only screenshot functionality 56 | 57 | ### Pagination System 58 | 59 | Large datasets are handled through configurable pagination: 60 | - Default limit: 4000 cells 61 | - Configurable via `EXCEL_MCP_PAGING_CELLS_LIMIT` environment variable 62 | - Implemented in `internal/excel/pagination.go` 63 | 64 | ## File Structure 65 | 66 | ``` 67 | cmd/excel-mcp-server/ # Main application entry point 68 | internal/ 69 | excel/ # Excel abstraction layer 70 | server/ # MCP server implementation 71 | tools/ # MCP tool implementations 72 | launcher/ # TypeScript launcher 73 | memory-bank/ # Development context and progress 74 | ``` 75 | 76 | ## Build System 77 | 78 | Uses GoReleaser (`.goreleaser.yaml`) to create cross-platform binaries: 79 | - Windows: amd64, 386, arm64 80 | - macOS: amd64, arm64 81 | - Linux: amd64, 386, arm64 82 | 83 | TypeScript launcher is compiled to `dist/launcher.js` and published to NPM. 84 | 85 | ## Platform Differences 86 | 87 | **Windows-specific features**: 88 | - Live Excel interaction via OLE automation 89 | - Screen capture capabilities 90 | - Requires Excel to be installed 91 | 92 | **Cross-platform features**: 93 | - File-based Excel operations only 94 | - No live editing capabilities 95 | - Works with xlsx, xlsm, xltx, xltm formats 96 | 97 | ## Configuration 98 | 99 | Environment variables: 100 | - `EXCEL_MCP_PAGING_CELLS_LIMIT` - Maximum cells per page (default: 4000) 101 | 102 | ## Dependencies 103 | 104 | **Go**: Requires Go 1.23.0+ with Go 1.24.0 toolchain 105 | **Node.js**: Requires Node.js 20.x+ for TypeScript compilation 106 | **Key packages**: 107 | - `github.com/mark3labs/mcp-go` - MCP framework 108 | - `github.com/xuri/excelize/v2` - Excel file operations 109 | - `github.com/go-ole/go-ole` - Windows OLE automation ``` -------------------------------------------------------------------------------- /.devcontainer/postCreateCommand.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | npm install -g @anthropic-ai/claude-code 6 | npm install -g @goreleaser/goreleaser 7 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | 2 | FROM node:20-slim AS release 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | RUN npm install -g @negokaz/[email protected] 8 | 9 | # Command to run the application 10 | ENTRYPOINT ["excel-mcp-server"] 11 | ``` -------------------------------------------------------------------------------- /cmd/excel-mcp-server/main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/negokaz/excel-mcp-server/internal/server" 8 | ) 9 | 10 | var ( 11 | version = "dev" 12 | ) 13 | 14 | func main() { 15 | s := server.New(version) 16 | err := s.Start() 17 | if err != nil { 18 | fmt.Fprintf(os.Stderr, "Failed to start the server: %v\n", err) 19 | os.Exit(1) 20 | } 21 | } 22 | ``` -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "Claude Code Dev Container", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | "features": { 7 | "ghcr.io/devcontainers/features/go:1":{} 8 | }, 9 | "mounts": [ 10 | "source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind,consistency=cached" 11 | ], 12 | "postCreateCommand": ".devcontainer/postCreateCommand.sh", 13 | "remoteUser": "node" 14 | } 15 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | {} 8 | commandFunction: 9 | # A function that produces the CLI command to start the MCP on stdio. 10 | |- 11 | (config) => ({ command: 'node', args: ['dist/launcher.js'], env: {} }) 12 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "outDir": "./dist", 12 | "rootDir": "./launcher", 13 | }, 14 | "include": ["launcher/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | ``` -------------------------------------------------------------------------------- /internal/tools/config.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | z "github.com/Oudwins/zog" 5 | "github.com/Oudwins/zog/zenv" 6 | ) 7 | 8 | type EnvConfig struct { 9 | EXCEL_MCP_PAGING_CELLS_LIMIT int 10 | } 11 | 12 | var configSchema = z.Struct(z.Shape{ 13 | "EXCEL_MCP_PAGING_CELLS_LIMIT": z.Int().GT(0).Default(4000), 14 | }) 15 | 16 | func LoadConfig() (EnvConfig, z.ZogIssueMap) { 17 | config := EnvConfig{} 18 | issues := configSchema.Parse(zenv.NewDataProvider(), &config) 19 | return config, issues 20 | } 21 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@negokaz/excel-mcp-server", 3 | "version": "0.12.0", 4 | "description": "An MCP server that reads and writes spreadsheet data to MS Excel file", 5 | "author": "negokaz", 6 | "license": "MIT", 7 | "bin": { 8 | "excel-mcp-server": "dist/launcher.js" 9 | }, 10 | "scripts": { 11 | "build": "goreleaser build --snapshot --clean && tsc", 12 | "watch": "tsc --watch", 13 | "debug": "npx @modelcontextprotocol/inspector dist/launcher.js" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^22.13.4", 17 | "typescript": "^5.7.3" 18 | }, 19 | "publishConfig": { 20 | "access": "public" 21 | } 22 | } 23 | ``` -------------------------------------------------------------------------------- /internal/mcp/error.go: -------------------------------------------------------------------------------- ```go 1 | package mcp 2 | 3 | import ( 4 | "fmt" 5 | 6 | z "github.com/Oudwins/zog" 7 | "github.com/mark3labs/mcp-go/mcp" 8 | ) 9 | 10 | func NewToolResultInvalidArgumentError(message string) *mcp.CallToolResult { 11 | return mcp.NewToolResultError(fmt.Sprintf("Invalid argument: %s", message)) 12 | } 13 | 14 | func NewToolResultZogIssueMap(errs z.ZogIssueMap) *mcp.CallToolResult { 15 | issues := z.Issues.SanitizeMap(errs) 16 | 17 | var issueResults []mcp.Content 18 | for k, messages := range issues { 19 | for _, message := range messages { 20 | issueResults = append(issueResults, mcp.NewTextContent(fmt.Sprintf("Invalid argument: %s: %s", k, message))) 21 | } 22 | } 23 | 24 | return &mcp.CallToolResult{ 25 | Content: issueResults, 26 | IsError: true, 27 | } 28 | } 29 | ``` -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- ```go 1 | package server 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/mark3labs/mcp-go/server" 7 | "github.com/negokaz/excel-mcp-server/internal/tools" 8 | ) 9 | 10 | type ExcelServer struct { 11 | server *server.MCPServer 12 | } 13 | 14 | func New(version string) *ExcelServer { 15 | s := &ExcelServer{} 16 | s.server = server.NewMCPServer( 17 | "excel-mcp-server", 18 | version, 19 | ) 20 | tools.AddExcelDescribeSheetsTool(s.server) 21 | tools.AddExcelReadSheetTool(s.server) 22 | if runtime.GOOS == "windows" { 23 | tools.AddExcelScreenCaptureTool(s.server) 24 | } 25 | tools.AddExcelWriteToSheetTool(s.server) 26 | tools.AddExcelCreateTableTool(s.server) 27 | tools.AddExcelCopySheetTool(s.server) 28 | tools.AddExcelFormatRangeTool(s.server) 29 | return s 30 | } 31 | 32 | func (s *ExcelServer) Start() error { 33 | return server.ServeStdio(s.server) 34 | } 35 | ``` -------------------------------------------------------------------------------- /launcher/launcher.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import * as path from 'path' 3 | import * as childProcess from 'child_process' 4 | 5 | const BINARY_DISTRIBUTION_PACKAGES: any = { 6 | win32_ia32: "excel-mcp-server_windows_386_sse2", 7 | win32_x64: "excel-mcp-server_windows_amd64_v1", 8 | win32_arm64: "excel-mcp-server_windows_arm64_v8.0", 9 | darwin_x64: "excel-mcp-server_darwin_amd64_v1", 10 | darwin_arm64: "excel-mcp-server_darwin_arm64_v8.0", 11 | linux_ia32: "excel-mcp-server_linux_386_sse2", 12 | linux_x64: "excel-mcp-server_linux_amd64_v1", 13 | linux_arm64: "excel-mcp-server_linux_arm64_v8.0", 14 | } 15 | 16 | function getBinaryPath(): string { 17 | const suffix = process.platform === 'win32' ? '.exe' : ''; 18 | const pkg = BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`]; 19 | if (pkg) { 20 | return path.resolve(__dirname, pkg, `excel-mcp-server${suffix}`); 21 | } else { 22 | throw new Error(`Unsupported platform: ${process.platform}_${process.arch}`); 23 | } 24 | } 25 | 26 | childProcess.execFileSync(getBinaryPath(), process.argv, { 27 | stdio: 'inherit', 28 | }); 29 | ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Publish Package to npmjs 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: stable 18 | 19 | - name: Install GoReleaser 20 | uses: goreleaser/goreleaser-action@v6 21 | with: 22 | install-only: true 23 | distribution: goreleaser 24 | # 'latest', 'nightly', or a semver 25 | version: "~> v2" 26 | 27 | - name: Setup Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: '20.x' 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Build 37 | run: npm run build 38 | 39 | - name: Publish to npmjs 40 | run: npm publish --no-git-checks 41 | env: 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | 44 | - name: Create GitHub Releases 45 | run: goreleaser release --clean 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | ``` -------------------------------------------------------------------------------- /internal/excel/util.go: -------------------------------------------------------------------------------- ```go 1 | package excel 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "regexp" 8 | 9 | "github.com/xuri/excelize/v2" 10 | ) 11 | 12 | // parseRange parses Excel's range string (e.g. A1:C10 or A1) 13 | func ParseRange(rangeStr string) (int, int, int, int, error) { 14 | re := regexp.MustCompile(`^(\$?[A-Z]+\$?\d+)(?::(\$?[A-Z]+\$?\d+))?$`) 15 | matches := re.FindStringSubmatch(rangeStr) 16 | if matches == nil { 17 | return 0, 0, 0, 0, fmt.Errorf("invalid range format: %s", rangeStr) 18 | } 19 | startCol, startRow, err := excelize.CellNameToCoordinates(matches[1]) 20 | if err != nil { 21 | return 0, 0, 0, 0, err 22 | } 23 | 24 | if matches[2] == "" { 25 | // Single cell case 26 | return startCol, startRow, startCol, startRow, nil 27 | } 28 | 29 | endCol, endRow, err := excelize.CellNameToCoordinates(matches[2]) 30 | if err != nil { 31 | return 0, 0, 0, 0, err 32 | } 33 | return startCol, startRow, endCol, endRow, nil 34 | } 35 | 36 | func NormalizeRange(rangeStr string) string { 37 | startCol, startRow, endCol, endRow, _ := ParseRange(rangeStr) 38 | startCell, _ := excelize.CoordinatesToCellName(startCol, startRow) 39 | endCell, _ := excelize.CoordinatesToCellName(endCol, endRow) 40 | return fmt.Sprintf("%s:%s", startCell, endCell) 41 | } 42 | 43 | // FileIsNotReadable checks if a file is not writable 44 | func FileIsNotWritable(absolutePath string) bool { 45 | f, err := os.OpenFile(path.Clean(absolutePath), os.O_WRONLY, os.ModePerm) 46 | if err != nil { 47 | return true 48 | } 49 | defer f.Close() 50 | return false 51 | } 52 | ``` -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM node:20 2 | 3 | ARG TZ 4 | ENV TZ="$TZ" 5 | 6 | # Install basic development tools and iptables/ipset 7 | RUN apt update && apt install -y less \ 8 | git \ 9 | procps \ 10 | sudo \ 11 | fzf \ 12 | zsh \ 13 | man-db \ 14 | unzip \ 15 | gnupg2 \ 16 | gh \ 17 | iptables \ 18 | ipset \ 19 | iproute2 \ 20 | dnsutils \ 21 | aggregate \ 22 | jq 23 | 24 | # Ensure default node user has access to /usr/local/share 25 | RUN mkdir -p /usr/local/share/npm-global && \ 26 | chown -R node:node /usr/local/share 27 | 28 | ARG USERNAME=node 29 | 30 | # Persist bash history. 31 | RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ 32 | && mkdir /commandhistory \ 33 | && touch /commandhistory/.bash_history \ 34 | && chown -R $USERNAME /commandhistory 35 | 36 | # Set `DEVCONTAINER` environment variable to help with orientation 37 | ENV DEVCONTAINER=true 38 | 39 | # Create workspace and config directories and set permissions 40 | RUN mkdir -p /workspace /home/node/.claude && \ 41 | chown -R node:node /workspace /home/node/.claude 42 | 43 | WORKDIR /workspace 44 | 45 | RUN ARCH=$(dpkg --print-architecture) && \ 46 | wget "https://github.com/dandavison/delta/releases/download/0.18.2/git-delta_0.18.2_${ARCH}.deb" && \ 47 | sudo dpkg -i "git-delta_0.18.2_${ARCH}.deb" && \ 48 | rm "git-delta_0.18.2_${ARCH}.deb" 49 | 50 | # Set up non-root user 51 | USER node 52 | 53 | # Install global packages 54 | ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global 55 | ENV PATH=$PATH:/usr/local/share/npm-global/bin 56 | 57 | # Set the default shell to zsh rather than sh 58 | ENV SHELL=/bin/zsh 59 | 60 | # Default powerline10k theme 61 | RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.0/zsh-in-docker.sh)" -- \ 62 | -p git \ 63 | -p fzf \ 64 | -a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \ 65 | -a "source /usr/share/doc/fzf/examples/completion.zsh" \ 66 | -a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ 67 | -x 68 | 69 | # Install Claude 70 | RUN npm install -g @anthropic-ai/claude-code 71 | 72 | # Copy and set up firewall script 73 | COPY init-firewall.sh /usr/local/bin/ 74 | USER root 75 | RUN chmod +x /usr/local/bin/init-firewall.sh && \ 76 | echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \ 77 | chmod 0440 /etc/sudoers.d/node-firewall 78 | USER node 79 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_copy_sheet.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html" 7 | 8 | z "github.com/Oudwins/zog" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | excel "github.com/negokaz/excel-mcp-server/internal/excel" 12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 13 | ) 14 | 15 | type ExcelCopySheetArguments struct { 16 | FileAbsolutePath string `zog:"fileAbsolutePath"` 17 | SrcSheetName string `zog:"srcSheetName"` 18 | DstSheetName string `zog:"dstSheetName"` 19 | } 20 | 21 | var excelCopySheetArgumentsSchema = z.Struct(z.Shape{ 22 | "fileAbsolutePath": z.String().Required(), 23 | "srcSheetName": z.String().Required(), 24 | "dstSheetName": z.String().Required(), 25 | }) 26 | 27 | func AddExcelCopySheetTool(server *server.MCPServer) { 28 | server.AddTool(mcp.NewTool("excel_copy_sheet", 29 | mcp.WithDescription("Copy existing sheet to a new sheet"), 30 | mcp.WithString("fileAbsolutePath", 31 | mcp.Required(), 32 | mcp.Description("Absolute path to the Excel file"), 33 | ), 34 | mcp.WithString("srcSheetName", 35 | mcp.Required(), 36 | mcp.Description("Source sheet name in the Excel file"), 37 | ), 38 | mcp.WithString("dstSheetName", 39 | mcp.Required(), 40 | mcp.Description("Sheet name to be copied"), 41 | ), 42 | ), handleCopySheet) 43 | } 44 | 45 | func handleCopySheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 46 | args := ExcelCopySheetArguments{} 47 | if issues := excelCopySheetArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { 48 | return imcp.NewToolResultZogIssueMap(issues), nil 49 | } 50 | return copySheet(args.FileAbsolutePath, args.SrcSheetName, args.DstSheetName) 51 | } 52 | 53 | func copySheet(fileAbsolutePath string, srcSheetName string, dstSheetName string) (*mcp.CallToolResult, error) { 54 | workbook, release, err := excel.OpenFile(fileAbsolutePath) 55 | if err != nil { 56 | return nil, err 57 | } 58 | defer release() 59 | 60 | srcSheet, err := workbook.FindSheet(srcSheetName) 61 | if err != nil { 62 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 63 | } 64 | defer srcSheet.Release() 65 | srcSheetName, err = srcSheet.Name() 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | if err := workbook.CopySheet(srcSheetName, dstSheetName); err != nil { 71 | return nil, err 72 | } 73 | if err := workbook.Save(); err != nil { 74 | return nil, err 75 | } 76 | 77 | result := "# Notice\n" 78 | result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName()) 79 | result += fmt.Sprintf("Sheet [%s] copied to [%s].\n", html.EscapeString(srcSheetName), html.EscapeString(dstSheetName)) 80 | return mcp.NewToolResultText(result), nil 81 | } 82 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_create_table.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html" 7 | 8 | z "github.com/Oudwins/zog" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | excel "github.com/negokaz/excel-mcp-server/internal/excel" 12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 13 | ) 14 | 15 | type ExcelCreateTableArguments struct { 16 | FileAbsolutePath string `zog:"fileAbsolutePath"` 17 | SheetName string `zog:"sheetName"` 18 | Range string `zog:"range"` 19 | TableName string `zog:"tableName"` 20 | } 21 | 22 | var excelCreateTableArgumentsSchema = z.Struct(z.Shape{ 23 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 24 | "sheetName": z.String().Required(), 25 | "range": z.String(), 26 | "tableName": z.String().Required(), 27 | }) 28 | 29 | func AddExcelCreateTableTool(server *server.MCPServer) { 30 | server.AddTool(mcp.NewTool("excel_create_table", 31 | mcp.WithDescription("Create a table in the Excel sheet"), 32 | mcp.WithString("fileAbsolutePath", 33 | mcp.Required(), 34 | mcp.Description("Absolute path to the Excel file"), 35 | ), 36 | mcp.WithString("sheetName", 37 | mcp.Required(), 38 | mcp.Description("Sheet name where the table is created"), 39 | ), 40 | mcp.WithString("range", 41 | mcp.Description("Range to be a table (e.g., \"A1:C10\")"), 42 | ), 43 | mcp.WithString("tableName", 44 | mcp.Required(), 45 | mcp.Description("Table name to be created"), 46 | ), 47 | ), handleCreateTable) 48 | } 49 | 50 | func handleCreateTable(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 51 | args := ExcelCreateTableArguments{} 52 | if issues := excelCreateTableArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { 53 | return imcp.NewToolResultZogIssueMap(issues), nil 54 | } 55 | return createTable(args.FileAbsolutePath, args.SheetName, args.Range, args.TableName) 56 | } 57 | 58 | func createTable(fileAbsolutePath string, sheetName string, tableRange string, tableName string) (*mcp.CallToolResult, error) { 59 | workbook, release, err := excel.OpenFile(fileAbsolutePath) 60 | if err != nil { 61 | return nil, err 62 | } 63 | defer release() 64 | 65 | worksheet, err := workbook.FindSheet(sheetName) 66 | if err != nil { 67 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 68 | } 69 | defer worksheet.Release() 70 | if err := worksheet.AddTable(tableRange, tableName); err != nil { 71 | return nil, err 72 | } 73 | if err := workbook.Save(); err != nil { 74 | return nil, err 75 | } 76 | 77 | result := "# Notice\n" 78 | result += fmt.Sprintf("backend: %s\n", workbook.GetBackendName()) 79 | result += fmt.Sprintf("Table [%s] created.\n", html.EscapeString(tableName)) 80 | return mcp.NewToolResultText(result), nil 81 | } 82 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_describe_sheets.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | z "github.com/Oudwins/zog" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/negokaz/excel-mcp-server/internal/excel" 11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 12 | ) 13 | 14 | type ExcelDescribeSheetsArguments struct { 15 | FileAbsolutePath string `zog:"fileAbsolutePath"` 16 | } 17 | 18 | var excelDescribeSheetsArgumentsSchema = z.Struct(z.Shape{ 19 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 20 | }) 21 | 22 | func AddExcelDescribeSheetsTool(server *server.MCPServer) { 23 | server.AddTool(mcp.NewTool("excel_describe_sheets", 24 | mcp.WithDescription("List all sheet information of specified Excel file"), 25 | mcp.WithString("fileAbsolutePath", 26 | mcp.Required(), 27 | mcp.Description("Absolute path to the Excel file"), 28 | ), 29 | ), handleDescribeSheets) 30 | } 31 | 32 | func handleDescribeSheets(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 33 | args := ExcelDescribeSheetsArguments{} 34 | issues := excelDescribeSheetsArgumentsSchema.Parse(request.Params.Arguments, &args) 35 | if len(issues) != 0 { 36 | return imcp.NewToolResultZogIssueMap(issues), nil 37 | } 38 | return describeSheets(args.FileAbsolutePath) 39 | } 40 | 41 | type Response struct { 42 | Backend string `json:"backend"` 43 | Sheets []Worksheet `json:"sheets"` 44 | } 45 | type Worksheet struct { 46 | Name string `json:"name"` 47 | UsedRange string `json:"usedRange"` 48 | Tables []Table `json:"tables"` 49 | PivotTables []PivotTable `json:"pivotTables"` 50 | PagingRanges []string `json:"pagingRanges"` 51 | } 52 | 53 | type Table struct { 54 | Name string `json:"name"` 55 | Range string `json:"range"` 56 | } 57 | 58 | type PivotTable struct { 59 | Name string `json:"name"` 60 | Range string `json:"range"` 61 | } 62 | 63 | func describeSheets(fileAbsolutePath string) (*mcp.CallToolResult, error) { 64 | config, issues := LoadConfig() 65 | if issues != nil { 66 | return imcp.NewToolResultZogIssueMap(issues), nil 67 | } 68 | workbook, release, err := excel.OpenFile(fileAbsolutePath) 69 | defer release() 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | sheetList, err := workbook.GetSheets() 75 | if err != nil { 76 | return nil, err 77 | } 78 | worksheets := make([]Worksheet, len(sheetList)) 79 | for i, sheet := range sheetList { 80 | defer sheet.Release() 81 | name, err := sheet.Name() 82 | if err != nil { 83 | return nil, err 84 | } 85 | usedRange, err := sheet.GetDimention() 86 | if err != nil { 87 | return nil, err 88 | } 89 | tables, err := sheet.GetTables() 90 | if err != nil { 91 | return nil, err 92 | } 93 | tableList := make([]Table, len(tables)) 94 | for i, table := range tables { 95 | tableList[i] = Table{ 96 | Name: table.Name, 97 | Range: table.Range, 98 | } 99 | } 100 | pivotTables, err := sheet.GetPivotTables() 101 | if err != nil { 102 | return nil, err 103 | } 104 | pivotTableList := make([]PivotTable, len(pivotTables)) 105 | for i, pivotTable := range pivotTables { 106 | pivotTableList[i] = PivotTable{ 107 | Name: pivotTable.Name, 108 | Range: pivotTable.Range, 109 | } 110 | } 111 | var pagingRanges []string 112 | strategy, err := sheet.GetPagingStrategy(config.EXCEL_MCP_PAGING_CELLS_LIMIT) 113 | if err == nil { 114 | pagingService := excel.NewPagingRangeService(strategy) 115 | pagingRanges = pagingService.GetPagingRanges() 116 | } 117 | worksheets[i] = Worksheet{ 118 | Name: name, 119 | UsedRange: usedRange, 120 | Tables: tableList, 121 | PivotTables: pivotTableList, 122 | PagingRanges: pagingRanges, 123 | } 124 | } 125 | response := Response{ 126 | Backend: workbook.GetBackendName(), 127 | Sheets: worksheets, 128 | } 129 | jsonBytes, err := json.MarshalIndent(response, "", " ") 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | return mcp.NewToolResultText(string(jsonBytes)), nil 135 | } 136 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_screen_capture.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | z "github.com/Oudwins/zog" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/negokaz/excel-mcp-server/internal/excel" 11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 12 | ) 13 | 14 | type ExcelScreenCaptureArguments struct { 15 | FileAbsolutePath string `zog:"fileAbsolutePath"` 16 | SheetName string `zog:"sheetName"` 17 | Range string `zog:"range"` 18 | } 19 | 20 | var ExcelScreenCaptureArgumentsSchema = z.Struct(z.Shape{ 21 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 22 | "sheetName": z.String().Required(), 23 | "range": z.String(), 24 | }) 25 | 26 | func AddExcelScreenCaptureTool(server *server.MCPServer) { 27 | server.AddTool(mcp.NewTool("excel_screen_capture", 28 | mcp.WithDescription("[Windows only] Take a screenshot of the Excel sheet with pagination."), 29 | mcp.WithString("fileAbsolutePath", 30 | mcp.Required(), 31 | mcp.Description("Absolute path to the Excel file"), 32 | ), 33 | mcp.WithString("sheetName", 34 | mcp.Required(), 35 | mcp.Description("Sheet name in the Excel file"), 36 | ), 37 | mcp.WithString("range", 38 | mcp.Description("Range of cells to read in the Excel sheet (e.g., \"A1:C10\"). [default: first paging range]"), 39 | ), 40 | ), handleScreenCapture) 41 | } 42 | 43 | func handleScreenCapture(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 44 | args := ExcelScreenCaptureArguments{} 45 | issues := ExcelScreenCaptureArgumentsSchema.Parse(request.Params.Arguments, &args) 46 | if len(issues) != 0 { 47 | return imcp.NewToolResultZogIssueMap(issues), nil 48 | } 49 | return readSheetImage(args.FileAbsolutePath, args.SheetName, args.Range) 50 | } 51 | 52 | func readSheetImage(fileAbsolutePath string, sheetName string, rangeStr string) (*mcp.CallToolResult, error) { 53 | workbook, releaseWorkbook, err := excel.NewExcelOle(fileAbsolutePath) 54 | defer releaseWorkbook() 55 | if err != nil { 56 | workbook, releaseWorkbook, err = excel.NewExcelOleWithNewObject(fileAbsolutePath) 57 | defer releaseWorkbook() 58 | if err != nil { 59 | return imcp.NewToolResultInvalidArgumentError(fmt.Errorf("failed to open workbook: %w", err).Error()), nil 60 | } 61 | } 62 | 63 | worksheet, err := workbook.FindSheet(sheetName) 64 | if err != nil { 65 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 66 | } 67 | defer worksheet.Release() 68 | 69 | pagingStrategy, err := worksheet.GetPagingStrategy(5000) 70 | if err != nil { 71 | return nil, err 72 | } 73 | pagingService := excel.NewPagingRangeService(pagingStrategy) 74 | 75 | allRanges := pagingService.GetPagingRanges() 76 | if len(allRanges) == 0 { 77 | return imcp.NewToolResultInvalidArgumentError("no range available to read"), nil 78 | } 79 | 80 | var currentRange string 81 | if rangeStr == "" && len(allRanges) > 0 { 82 | // range が指定されていない場合は最初の Range を使用 83 | currentRange = allRanges[0] 84 | } else { 85 | // range が指定されている場合は指定された範囲を使用 86 | currentRange = rangeStr 87 | } 88 | // Find next paging range if current range matches a paging range 89 | nextRange := pagingService.FindNextRange(allRanges, currentRange) 90 | 91 | base64image, err := worksheet.CapturePicture(currentRange) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to copy range to image: %w", err) 94 | } 95 | 96 | text := "# Metadata\n" 97 | text += fmt.Sprintf("- backend: %s\n", workbook.GetBackendName()) 98 | text += fmt.Sprintf("- sheet name: %s\n", sheetName) 99 | text += fmt.Sprintf("- read range: %s\n", currentRange) 100 | text += "# Notice\n" 101 | if nextRange != "" { 102 | text += "This sheet has more ranges.\n" 103 | text += "To read the next range, you should specify 'range' argument as follows.\n" 104 | text += fmt.Sprintf("`{ \"range\": \"%s\" }`", nextRange) 105 | } else { 106 | text += "This is the last range or no more ranges available.\n" 107 | } 108 | 109 | // 結果を返却 110 | return mcp.NewToolResultImage( 111 | text, 112 | base64image, 113 | "image/png", 114 | ), nil 115 | } 116 | ``` -------------------------------------------------------------------------------- /.devcontainer/init-firewall.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | set -euo pipefail # Exit on error, undefined vars, and pipeline failures 3 | IFS=$'\n\t' # Stricter word splitting 4 | 5 | # Flush existing rules and delete existing ipsets 6 | iptables -F 7 | iptables -X 8 | iptables -t nat -F 9 | iptables -t nat -X 10 | iptables -t mangle -F 11 | iptables -t mangle -X 12 | ipset destroy allowed-domains 2>/dev/null || true 13 | 14 | # First allow DNS and localhost before any restrictions 15 | # Allow outbound DNS 16 | iptables -A OUTPUT -p udp --dport 53 -j ACCEPT 17 | # Allow inbound DNS responses 18 | iptables -A INPUT -p udp --sport 53 -j ACCEPT 19 | # Allow outbound SSH 20 | iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT 21 | # Allow inbound SSH responses 22 | iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT 23 | # Allow localhost 24 | iptables -A INPUT -i lo -j ACCEPT 25 | iptables -A OUTPUT -o lo -j ACCEPT 26 | 27 | # Create ipset with CIDR support 28 | ipset create allowed-domains hash:net 29 | 30 | # Fetch GitHub meta information and aggregate + add their IP ranges 31 | echo "Fetching GitHub IP ranges..." 32 | gh_ranges=$(curl -s https://api.github.com/meta) 33 | if [ -z "$gh_ranges" ]; then 34 | echo "ERROR: Failed to fetch GitHub IP ranges" 35 | exit 1 36 | fi 37 | 38 | if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then 39 | echo "ERROR: GitHub API response missing required fields" 40 | exit 1 41 | fi 42 | 43 | echo "Processing GitHub IPs..." 44 | while read -r cidr; do 45 | if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then 46 | echo "ERROR: Invalid CIDR range from GitHub meta: $cidr" 47 | exit 1 48 | fi 49 | echo "Adding GitHub range $cidr" 50 | ipset add allowed-domains "$cidr" 51 | done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q) 52 | 53 | # Resolve and add other allowed domains 54 | for domain in \ 55 | "registry.npmjs.org" \ 56 | "api.anthropic.com" \ 57 | "sentry.io" \ 58 | "statsig.anthropic.com" \ 59 | "statsig.com"; do 60 | echo "Resolving $domain..." 61 | ips=$(dig +short A "$domain") 62 | if [ -z "$ips" ]; then 63 | echo "ERROR: Failed to resolve $domain" 64 | exit 1 65 | fi 66 | 67 | while read -r ip; do 68 | if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then 69 | echo "ERROR: Invalid IP from DNS for $domain: $ip" 70 | exit 1 71 | fi 72 | echo "Adding $ip for $domain" 73 | ipset add allowed-domains "$ip" 74 | done < <(echo "$ips") 75 | done 76 | 77 | # Get host IP from default route 78 | HOST_IP=$(ip route | grep default | cut -d" " -f3) 79 | if [ -z "$HOST_IP" ]; then 80 | echo "ERROR: Failed to detect host IP" 81 | exit 1 82 | fi 83 | 84 | HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/") 85 | echo "Host network detected as: $HOST_NETWORK" 86 | 87 | # Set up remaining iptables rules 88 | iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT 89 | iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT 90 | 91 | # Set default policies to DROP first 92 | iptables -P INPUT DROP 93 | iptables -P FORWARD DROP 94 | iptables -P OUTPUT DROP 95 | 96 | # First allow established connections for already approved traffic 97 | iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT 98 | iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT 99 | 100 | # Then allow only specific outbound traffic to allowed domains 101 | iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT 102 | 103 | echo "Firewall configuration complete" 104 | echo "Verifying firewall rules..." 105 | if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then 106 | echo "ERROR: Firewall verification failed - was able to reach https://example.com" 107 | exit 1 108 | else 109 | echo "Firewall verification passed - unable to reach https://example.com as expected" 110 | fi 111 | 112 | # Verify GitHub API access 113 | if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then 114 | echo "ERROR: Firewall verification failed - unable to reach https://api.github.com" 115 | exit 1 116 | else 117 | echo "Firewall verification passed - able to reach https://api.github.com as expected" 118 | fi 119 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_write_to_sheet.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | z "github.com/Oudwins/zog" 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | "github.com/negokaz/excel-mcp-server/internal/excel" 11 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 12 | "github.com/xuri/excelize/v2" 13 | ) 14 | 15 | type ExcelWriteToSheetArguments struct { 16 | FileAbsolutePath string `zog:"fileAbsolutePath"` 17 | SheetName string `zog:"sheetName"` 18 | NewSheet bool `zog:"newSheet"` 19 | Range string `zog:"range"` 20 | Values [][]string `zog:"values"` 21 | } 22 | 23 | var excelWriteToSheetArgumentsSchema = z.Struct(z.Shape{ 24 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 25 | "sheetName": z.String().Required(), 26 | "newSheet": z.Bool().Required().Default(false), 27 | "range": z.String().Required(), 28 | "values": z.Slice(z.Slice(z.String())).Required(), 29 | }) 30 | 31 | func AddExcelWriteToSheetTool(server *server.MCPServer) { 32 | server.AddTool(mcp.NewTool("excel_write_to_sheet", 33 | mcp.WithDescription("Write values to the Excel sheet"), 34 | mcp.WithString("fileAbsolutePath", 35 | mcp.Required(), 36 | mcp.Description("Absolute path to the Excel file"), 37 | ), 38 | mcp.WithString("sheetName", 39 | mcp.Required(), 40 | mcp.Description("Sheet name in the Excel file"), 41 | ), 42 | mcp.WithBoolean("newSheet", 43 | mcp.Required(), 44 | mcp.Description("Create a new sheet if true, otherwise write to the existing sheet"), 45 | ), 46 | mcp.WithString("range", 47 | mcp.Required(), 48 | mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C10\")"), 49 | ), 50 | mcp.WithArray("values", 51 | mcp.Required(), 52 | mcp.Description("Values to write to the Excel sheet. If the value is a formula, it should start with \"=\""), 53 | mcp.Items(map[string]any{ 54 | "type": "array", 55 | "items": map[string]any{ 56 | "anyOf": []any{ 57 | map[string]any{ 58 | "type": "string", 59 | }, 60 | map[string]any{ 61 | "type": "number", 62 | }, 63 | map[string]any{ 64 | "type": "boolean", 65 | }, 66 | map[string]any{ 67 | "type": "null", 68 | }, 69 | }, 70 | }, 71 | }), 72 | ), 73 | ), handleWriteToSheet) 74 | } 75 | 76 | func handleWriteToSheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 77 | args := ExcelWriteToSheetArguments{} 78 | issues := excelWriteToSheetArgumentsSchema.Parse(request.Params.Arguments, &args) 79 | if len(issues) != 0 { 80 | return imcp.NewToolResultZogIssueMap(issues), nil 81 | } 82 | 83 | // zog が any type のスキーマをサポートしていないため、自力で実装 84 | valuesArg, ok := request.GetArguments()["values"].([]any) 85 | if !ok { 86 | return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil 87 | } 88 | values := make([][]any, len(valuesArg)) 89 | for i, v := range valuesArg { 90 | value, ok := v.([]any) 91 | if !ok { 92 | return imcp.NewToolResultInvalidArgumentError("values must be a 2D array"), nil 93 | } 94 | values[i] = value 95 | } 96 | 97 | return writeSheet(args.FileAbsolutePath, args.SheetName, args.NewSheet, args.Range, values) 98 | } 99 | 100 | func writeSheet(fileAbsolutePath string, sheetName string, newSheet bool, rangeStr string, values [][]any) (*mcp.CallToolResult, error) { 101 | workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) 102 | if err != nil { 103 | return nil, err 104 | } 105 | defer closeFn() 106 | 107 | startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) 108 | if err != nil { 109 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 110 | } 111 | 112 | // データの整合性チェック 113 | rangeRowSize := endRow - startRow + 1 114 | if len(values) != rangeRowSize { 115 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of rows in data (%d) does not match range size (%d)", len(values), rangeRowSize)), nil 116 | } 117 | 118 | if newSheet { 119 | if err := workbook.CreateNewSheet(sheetName); err != nil { 120 | return nil, err 121 | } 122 | } 123 | 124 | // シートの取得 125 | worksheet, err := workbook.FindSheet(sheetName) 126 | if err != nil { 127 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 128 | } 129 | defer worksheet.Release() 130 | 131 | // データの書き込み 132 | wroteFormula := false 133 | for i, row := range values { 134 | rangeColumnSize := endCol - startCol + 1 135 | if len(row) != rangeColumnSize { 136 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of columns in row %d (%d) does not match range size (%d)", i, len(row), rangeColumnSize)), nil 137 | } 138 | for j, cellValue := range row { 139 | cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) 140 | if err != nil { 141 | return nil, err 142 | } 143 | if cellStr, ok := cellValue.(string); ok && isFormula(cellStr) { 144 | // if cellValue is formula, set it as formula 145 | err = worksheet.SetFormula(cell, cellStr) 146 | wroteFormula = true 147 | } else { 148 | // if cellValue is not formula, set it as value 149 | err = worksheet.SetValue(cell, cellValue) 150 | } 151 | if err != nil { 152 | return nil, err 153 | } 154 | } 155 | } 156 | 157 | if err := workbook.Save(); err != nil { 158 | return nil, err 159 | } 160 | 161 | // HTMLテーブルの生成 162 | var table *string 163 | if wroteFormula { 164 | table, err = CreateHTMLTableOfFormula(worksheet, startCol, startRow, endCol, endRow) 165 | } else { 166 | table, err = CreateHTMLTableOfValues(worksheet, startCol, startRow, endCol, endRow) 167 | } 168 | if err != nil { 169 | return nil, err 170 | } 171 | html := "<h2>Written Sheet</h2>\n" 172 | html += *table + "\n" 173 | html += "<h2>Metadata</h2>\n" 174 | html += "<ul>\n" 175 | html += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) 176 | html += fmt.Sprintf("<li>sheet name: %s</li>\n", sheetName) 177 | html += fmt.Sprintf("<li>read range: %s</li>\n", rangeStr) 178 | html += "</ul>\n" 179 | html += "<h2>Notice</h2>\n" 180 | html += "<p>Values wrote successfully.</p>\n" 181 | 182 | return mcp.NewToolResultText(html), nil 183 | } 184 | 185 | func isFormula(value string) bool { 186 | return len(value) > 0 && value[0] == '=' 187 | } 188 | ``` -------------------------------------------------------------------------------- /internal/tools/excel_read_sheet.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html" 7 | 8 | z "github.com/Oudwins/zog" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | excel "github.com/negokaz/excel-mcp-server/internal/excel" 12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 13 | ) 14 | 15 | type ExcelReadSheetArguments struct { 16 | FileAbsolutePath string `zog:"fileAbsolutePath"` 17 | SheetName string `zog:"sheetName"` 18 | Range string `zog:"range"` 19 | ShowFormula bool `zog:"showFormula"` 20 | ShowStyle bool `zog:"showStyle"` 21 | } 22 | 23 | var excelReadSheetArgumentsSchema = z.Struct(z.Shape{ 24 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 25 | "sheetName": z.String().Required(), 26 | "range": z.String(), 27 | "showFormula": z.Bool().Default(false), 28 | "showStyle": z.Bool().Default(false), 29 | }) 30 | 31 | func AddExcelReadSheetTool(server *server.MCPServer) { 32 | server.AddTool(mcp.NewTool("excel_read_sheet", 33 | mcp.WithDescription("Read values from Excel sheet with pagination."), 34 | mcp.WithString("fileAbsolutePath", 35 | mcp.Required(), 36 | mcp.Description("Absolute path to the Excel file"), 37 | ), 38 | mcp.WithString("sheetName", 39 | mcp.Required(), 40 | mcp.Description("Sheet name in the Excel file"), 41 | ), 42 | mcp.WithString("range", 43 | mcp.Description("Range of cells to read in the Excel sheet (e.g., \"A1:C10\"). [default: first paging range]"), 44 | ), 45 | mcp.WithBoolean("showFormula", 46 | mcp.Description("Show formula instead of value"), 47 | ), 48 | mcp.WithBoolean("showStyle", 49 | mcp.Description("Show style information for cells"), 50 | ), 51 | ), handleReadSheet) 52 | } 53 | 54 | func handleReadSheet(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 55 | args := ExcelReadSheetArguments{} 56 | if issues := excelReadSheetArgumentsSchema.Parse(request.Params.Arguments, &args); len(issues) != 0 { 57 | return imcp.NewToolResultZogIssueMap(issues), nil 58 | } 59 | return readSheet(args.FileAbsolutePath, args.SheetName, args.Range, args.ShowFormula, args.ShowStyle) 60 | } 61 | 62 | func readSheet(fileAbsolutePath string, sheetName string, valueRange string, showFormula bool, showStyle bool) (*mcp.CallToolResult, error) { 63 | config, issues := LoadConfig() 64 | if issues != nil { 65 | return imcp.NewToolResultZogIssueMap(issues), nil 66 | } 67 | 68 | workbook, release, err := excel.OpenFile(fileAbsolutePath) 69 | if err != nil { 70 | return nil, err 71 | } 72 | defer release() 73 | 74 | worksheet, err := workbook.FindSheet(sheetName) 75 | if err != nil { 76 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 77 | } 78 | defer worksheet.Release() 79 | 80 | // ページング戦略の初期化 81 | strategy, err := worksheet.GetPagingStrategy(config.EXCEL_MCP_PAGING_CELLS_LIMIT) 82 | if err != nil { 83 | return nil, err 84 | } 85 | pagingService := excel.NewPagingRangeService(strategy) 86 | 87 | // 利用可能な範囲を取得 88 | allRanges := pagingService.GetPagingRanges() 89 | if len(allRanges) == 0 { 90 | return imcp.NewToolResultInvalidArgumentError("no range available to read"), nil 91 | } 92 | 93 | // 現在の範囲を決定 94 | currentRange := valueRange 95 | if currentRange == "" && len(allRanges) > 0 { 96 | currentRange = allRanges[0] 97 | } 98 | 99 | // Find next paging range if current range matches a paging range 100 | nextRange := pagingService.FindNextRange(allRanges, currentRange) 101 | // Validate the current range against the used range 102 | usedRange, err := worksheet.GetDimention() 103 | if err != nil { 104 | return nil, err 105 | } 106 | if err := validateRangeWithinUsedRange(currentRange, usedRange); err != nil { 107 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 108 | } 109 | 110 | // 範囲を解析 111 | startCol, startRow, endCol, endRow, err := excel.ParseRange(currentRange) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | // HTMLテーブルの生成 117 | var table *string 118 | if showStyle { 119 | if showFormula { 120 | table, err = CreateHTMLTableOfFormulaWithStyle(worksheet, startCol, startRow, endCol, endRow) 121 | } else { 122 | table, err = CreateHTMLTableOfValuesWithStyle(worksheet, startCol, startRow, endCol, endRow) 123 | } 124 | } else { 125 | if showFormula { 126 | table, err = CreateHTMLTableOfFormula(worksheet, startCol, startRow, endCol, endRow) 127 | } else { 128 | table, err = CreateHTMLTableOfValues(worksheet, startCol, startRow, endCol, endRow) 129 | } 130 | } 131 | if err != nil { 132 | return nil, err 133 | } 134 | 135 | result := "<h2>Read Sheet</h2>\n" 136 | result += *table + "\n" 137 | result += "<h2>Metadata</h2>\n" 138 | result += "<ul>\n" 139 | result += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) 140 | result += fmt.Sprintf("<li>sheet name: %s</li>\n", html.EscapeString(sheetName)) 141 | result += fmt.Sprintf("<li>read range: %s</li>\n", currentRange) 142 | result += "</ul>\n" 143 | result += "<h2>Notice</h2>\n" 144 | if nextRange != "" { 145 | result += "<p>This sheet has more ranges.</p>\n" 146 | result += "<p>To read the next range, you should specify 'range' argument as follows.</p>\n" 147 | result += fmt.Sprintf("<code>{ \"range\": \"%s\" }</code>\n", nextRange) 148 | } else { 149 | result += "<p>This is the last range or no more ranges available.</p>\n" 150 | } 151 | return mcp.NewToolResultText(result), nil 152 | } 153 | 154 | func validateRangeWithinUsedRange(targetRange, usedRange string) error { 155 | // Parse target range 156 | targetStartCol, targetStartRow, targetEndCol, targetEndRow, err := excel.ParseRange(targetRange) 157 | if err != nil { 158 | return fmt.Errorf("failed to parse target range: %w", err) 159 | } 160 | 161 | // Parse used range 162 | usedStartCol, usedStartRow, usedEndCol, usedEndRow, err := excel.ParseRange(usedRange) 163 | if err != nil { 164 | return fmt.Errorf("failed to parse used range: %w", err) 165 | } 166 | 167 | // Check if target range is within used range 168 | if targetStartCol < usedStartCol || targetStartRow < usedStartRow || 169 | targetEndCol > usedEndCol || targetEndRow > usedEndRow { 170 | return fmt.Errorf("range is outside of used range: %s is not within %s", targetRange, usedRange) 171 | } 172 | 173 | return nil 174 | } 175 | ``` -------------------------------------------------------------------------------- /docs/design/excel-style-schema.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Excel Style Structure Definition 2 | 3 | This document presents JsonSchema definitions for exchanging Excel styles through MCP (Model Context Protocol), based on the Excelize library's style API. 4 | 5 | ## Target Style Elements 6 | 7 | - Border 8 | - Font 9 | - Fill 10 | - NumFmt (Number Format) 11 | - DecimalPlaces 12 | 13 | ## JsonSchema Definition 14 | 15 | ### ExcelStyle Structure 16 | 17 | ```json 18 | { 19 | "$schema": "http://json-schema.org/draft-07/schema#", 20 | "type": "object", 21 | "title": "ExcelStyle", 22 | "description": "Excel cell style configuration", 23 | "properties": { 24 | "border": { 25 | "type": "array", 26 | "description": "Border configuration for cell edges", 27 | "items": { 28 | "$ref": "#/definitions/Border" 29 | } 30 | }, 31 | "font": { 32 | "$ref": "#/definitions/Font", 33 | "description": "Font configuration" 34 | }, 35 | "fill": { 36 | "$ref": "#/definitions/Fill", 37 | "description": "Fill pattern and color configuration" 38 | }, 39 | "numFmt": { 40 | "type": "string", 41 | "description": "Custom number format string", 42 | "example": "#,##0.00" 43 | }, 44 | "decimalPlaces": { 45 | "type": "integer", 46 | "description": "Number of decimal places (0-30)", 47 | "minimum": 0, 48 | "maximum": 30 49 | } 50 | }, 51 | "definitions": { 52 | "Border": { 53 | "type": "object", 54 | "description": "Border style configuration", 55 | "properties": { 56 | "type": { 57 | "type": "string", 58 | "description": "Border position", 59 | "enum": ["left", "right", "top", "bottom", "diagonalDown", "diagonalUp"] 60 | }, 61 | "color": { 62 | "type": "string", 63 | "description": "Border color in hex format", 64 | "pattern": "^#[0-9A-Fa-f]{6}$", 65 | "example": "#000000" 66 | }, 67 | "style": { 68 | "type": "string", 69 | "description": "Border style", 70 | "enum": ["none", "continuous", "dash", "dashDot", "dashDotDot", "dot", "double", "hair", "medium", "mediumDash", "mediumDashDot", "mediumDashDotDot", "slantDashDot", "thick"] 71 | } 72 | }, 73 | "required": ["type"], 74 | "additionalProperties": false 75 | }, 76 | "Font": { 77 | "type": "object", 78 | "description": "Font style configuration", 79 | "properties": { 80 | "bold": { 81 | "type": "boolean", 82 | "description": "Bold text" 83 | }, 84 | "italic": { 85 | "type": "boolean", 86 | "description": "Italic text" 87 | }, 88 | "underline": { 89 | "type": "string", 90 | "description": "Underline style", 91 | "enum": ["none", "single", "double", "singleAccounting", "doubleAccounting"] 92 | }, 93 | "size": { 94 | "type": "number", 95 | "description": "Font size in points", 96 | "minimum": 1, 97 | "maximum": 409 98 | }, 99 | "strike": { 100 | "type": "boolean", 101 | "description": "Strikethrough text" 102 | }, 103 | "color": { 104 | "type": "string", 105 | "description": "Font color in hex format", 106 | "pattern": "^#[0-9A-Fa-f]{6}$", 107 | "example": "#000000" 108 | }, 109 | "vertAlign": { 110 | "type": "string", 111 | "description": "Vertical alignment", 112 | "enum": ["baseline", "superscript", "subscript"] 113 | } 114 | }, 115 | "additionalProperties": false 116 | }, 117 | "Fill": { 118 | "type": "object", 119 | "description": "Fill pattern and color configuration", 120 | "properties": { 121 | "type": { 122 | "type": "string", 123 | "description": "Fill type", 124 | "enum": ["gradient", "pattern"] 125 | }, 126 | "pattern": { 127 | "type": "string", 128 | "description": "Pattern style", 129 | "enum": ["none", "solid", "mediumGray", "darkGray", "lightGray", "darkHorizontal", "darkVertical", "darkDown", "darkUp", "darkGrid", "darkTrellis", "lightHorizontal", "lightVertical", "lightDown", "lightUp", "lightGrid", "lightTrellis", "gray125", "gray0625"] 130 | }, 131 | "color": { 132 | "type": "array", 133 | "description": "Fill colors in hex format", 134 | "items": { 135 | "type": "string", 136 | "pattern": "^#[0-9A-Fa-f]{6}$", 137 | "example": "#FFFFFF" 138 | } 139 | }, 140 | "shading": { 141 | "type": "string", 142 | "description": "Gradient shading direction", 143 | "enum": ["horizontal", "vertical", "diagonalDown", "diagonalUp", "fromCenter", "fromCorner"] 144 | } 145 | }, 146 | "additionalProperties": false 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | ## Usage Examples 153 | 154 | ### Basic Style Configuration 155 | 156 | ```json 157 | { 158 | "font": { 159 | "bold": true, 160 | "size": 12, 161 | "color": "#000000" 162 | }, 163 | "fill": { 164 | "type": "pattern", 165 | "pattern": "solid", 166 | "color": ["#FFFF00"] 167 | } 168 | } 169 | ``` 170 | 171 | ### Style with Borders 172 | 173 | ```json 174 | { 175 | "border": [ 176 | { 177 | "type": "top", 178 | "style": "continuous", 179 | "color": "#000000" 180 | }, 181 | { 182 | "type": "bottom", 183 | "style": "continuous", 184 | "color": "#000000" 185 | } 186 | ], 187 | "font": { 188 | "size": 10 189 | } 190 | } 191 | ``` 192 | 193 | ### Style with Number Format 194 | 195 | ```json 196 | { 197 | "numFmt": "#,##0.00", 198 | "decimalPlaces": 2, 199 | "font": { 200 | "size": 11 201 | } 202 | } 203 | ``` 204 | 205 | ## Implementation Notes 206 | 207 | 1. **Required Fields**: Only Border's `type` field is required; all others are optional 208 | 2. **Color Format**: Hexadecimal color codes (#RRGGBB format) 209 | 3. **Numeric Limits**: 210 | - `decimalPlaces`: Range 0-30 211 | - `border.style`: String identifiers (none, continuous, dash, etc.) 212 | - `fill.pattern`: String identifiers (none, solid, mediumGray, etc.) 213 | - `fill.shading`: String identifiers (horizontal, vertical, etc.) 214 | - `font.size`: Range 1-409 215 | 4. **Testing**: After implementation, test with actual Excel files 216 | 217 | ## Correspondence with Excelize 218 | 219 | 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 1 | package tools 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "regexp" 7 | 8 | z "github.com/Oudwins/zog" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | "github.com/negokaz/excel-mcp-server/internal/excel" 12 | imcp "github.com/negokaz/excel-mcp-server/internal/mcp" 13 | "github.com/xuri/excelize/v2" 14 | ) 15 | 16 | type ExcelFormatRangeArguments struct { 17 | FileAbsolutePath string `zog:"fileAbsolutePath"` 18 | SheetName string `zog:"sheetName"` 19 | Range string `zog:"range"` 20 | Styles [][]*excel.CellStyle `zog:"styles"` 21 | } 22 | 23 | var colorPattern, _ = regexp.Compile("^#[0-9A-Fa-f]{6}$") 24 | 25 | var excelFormatRangeArgumentsSchema = z.Struct(z.Shape{ 26 | "fileAbsolutePath": z.String().Test(AbsolutePathTest()).Required(), 27 | "sheetName": z.String().Required(), 28 | "range": z.String().Required(), 29 | "styles": z.Slice(z.Slice( 30 | z.Ptr(z.Struct(z.Shape{ 31 | "border": z.Slice(z.Struct(z.Shape{ 32 | "type": z.StringLike[excel.BorderType]().OneOf(excel.BorderTypeValues()).Required(), 33 | "color": z.String().Match(colorPattern).Default("#000000"), 34 | "style": z.StringLike[excel.BorderStyle]().OneOf(excel.BorderStyleValues()).Default(excel.BorderStyleContinuous), 35 | })).Default([]excel.Border{}), 36 | "font": z.Ptr(z.Struct(z.Shape{ 37 | "bold": z.Ptr(z.Bool()), 38 | "italic": z.Ptr(z.Bool()), 39 | "underline": z.Ptr(z.StringLike[excel.FontUnderline]().OneOf(excel.FontUnderlineValues())), 40 | "size": z.Ptr(z.Int().GTE(1).LTE(409)), 41 | "strike": z.Ptr(z.Bool()), 42 | "color": z.Ptr(z.String().Match(colorPattern)), 43 | "vertAlign": z.Ptr(z.StringLike[excel.FontVertAlign]().OneOf(excel.FontVertAlignValues())), 44 | })), 45 | "fill": z.Ptr(z.Struct(z.Shape{ 46 | "type": z.StringLike[excel.FillType]().OneOf(excel.FillTypeValues()).Default(excel.FillTypePattern), 47 | "pattern": z.StringLike[excel.FillPattern]().OneOf(excel.FillPatternValues()).Default(excel.FillPatternSolid), 48 | "color": z.Slice(z.String().Match(colorPattern)).Default([]string{}), 49 | "shading": z.Ptr(z.StringLike[excel.FillShading]().OneOf(excel.FillShadingValues())), 50 | })), 51 | "numFmt": z.Ptr(z.String()), 52 | "decimalPlaces": z.Ptr(z.Int().GTE(0).LTE(30)), 53 | }), 54 | ))).Required(), 55 | }) 56 | 57 | func AddExcelFormatRangeTool(server *server.MCPServer) { 58 | server.AddTool(mcp.NewTool("excel_format_range", 59 | mcp.WithDescription("Format cells in the Excel sheet with style information"), 60 | mcp.WithString("fileAbsolutePath", 61 | mcp.Required(), 62 | mcp.Description("Absolute path to the Excel file"), 63 | ), 64 | mcp.WithString("sheetName", 65 | mcp.Required(), 66 | mcp.Description("Sheet name in the Excel file"), 67 | ), 68 | mcp.WithString("range", 69 | mcp.Required(), 70 | mcp.Description("Range of cells in the Excel sheet (e.g., \"A1:C3\")"), 71 | ), 72 | mcp.WithArray("styles", 73 | mcp.Required(), 74 | 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."), 75 | mcp.Items(map[string]any{ 76 | "type": "array", 77 | "items": map[string]any{ 78 | "anyOf": []any{ 79 | map[string]any{ 80 | "type": "object", 81 | "description": "Style object for the cell", 82 | "properties": map[string]any{ 83 | "border": map[string]any{ 84 | "type": "array", 85 | "items": map[string]any{ 86 | "type": "object", 87 | "properties": map[string]any{ 88 | "type": map[string]any{ 89 | "type": "string", 90 | "enum": excel.BorderTypeValues(), 91 | }, 92 | "color": map[string]any{ 93 | "type": "string", 94 | "pattern": colorPattern.String(), 95 | }, 96 | "style": map[string]any{ 97 | "type": "string", 98 | "enum": excel.BorderStyleValues(), 99 | }, 100 | }, 101 | "required": []string{"type"}, 102 | }, 103 | }, 104 | "font": map[string]any{ 105 | "type": "object", 106 | "properties": map[string]any{ 107 | "bold": map[string]any{"type": "boolean"}, 108 | "italic": map[string]any{"type": "boolean"}, 109 | "underline": map[string]any{ 110 | "type": "string", 111 | "enum": excel.FontUnderlineValues(), 112 | }, 113 | "size": map[string]any{ 114 | "type": "number", 115 | "minimum": 1, 116 | "maximum": 409, 117 | }, 118 | "strike": map[string]any{"type": "boolean"}, 119 | "color": map[string]any{ 120 | "type": "string", 121 | "pattern": colorPattern.String(), 122 | }, 123 | "vertAlign": map[string]any{ 124 | "type": "string", 125 | "enum": excel.FontVertAlignValues(), 126 | }, 127 | }, 128 | }, 129 | "fill": map[string]any{ 130 | "type": "object", 131 | "properties": map[string]any{ 132 | "type": map[string]any{ 133 | "type": "string", 134 | "enum": []string{"gradient", "pattern"}, 135 | }, 136 | "pattern": map[string]any{ 137 | "type": "string", 138 | "enum": excel.FillPatternValues(), 139 | }, 140 | "color": map[string]any{ 141 | "type": "array", 142 | "items": map[string]any{ 143 | "type": "string", 144 | "pattern": colorPattern.String(), 145 | }, 146 | }, 147 | "shading": map[string]any{ 148 | "type": "string", 149 | "enum": excel.FillShadingValues(), 150 | }, 151 | }, 152 | "required": []string{"type", "pattern", "color"}, 153 | }, 154 | "numFmt": map[string]any{ 155 | "type": "string", 156 | "description": "Custom number format string", 157 | }, 158 | "decimalPlaces": map[string]any{ 159 | "type": "integer", 160 | "minimum": 0, 161 | "maximum": 30, 162 | }, 163 | }, 164 | }, 165 | map[string]any{ 166 | "type": "null", 167 | "description": "No style applied to this cell", 168 | }, 169 | }, 170 | }, 171 | }), 172 | ), 173 | ), handleFormatRange) 174 | } 175 | 176 | func handleFormatRange(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 177 | args := ExcelFormatRangeArguments{} 178 | issues := excelFormatRangeArgumentsSchema.Parse(request.Params.Arguments, &args) 179 | if len(issues) != 0 { 180 | return imcp.NewToolResultZogIssueMap(issues), nil 181 | } 182 | return formatRange(args.FileAbsolutePath, args.SheetName, args.Range, args.Styles) 183 | } 184 | 185 | func formatRange(fileAbsolutePath string, sheetName string, rangeStr string, styles [][]*excel.CellStyle) (*mcp.CallToolResult, error) { 186 | workbook, closeFn, err := excel.OpenFile(fileAbsolutePath) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer closeFn() 191 | 192 | startCol, startRow, endCol, endRow, err := excel.ParseRange(rangeStr) 193 | if err != nil { 194 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 195 | } 196 | 197 | // Check data consistency 198 | rangeRowSize := endRow - startRow + 1 199 | if len(styles) != rangeRowSize { 200 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style rows (%d) does not match range size (%d)", len(styles), rangeRowSize)), nil 201 | } 202 | 203 | // Get worksheet 204 | worksheet, err := workbook.FindSheet(sheetName) 205 | if err != nil { 206 | return imcp.NewToolResultInvalidArgumentError(err.Error()), nil 207 | } 208 | defer worksheet.Release() 209 | 210 | // Apply styles to each cell 211 | for i, styleRow := range styles { 212 | rangeColumnSize := endCol - startCol + 1 213 | if len(styleRow) != rangeColumnSize { 214 | return imcp.NewToolResultInvalidArgumentError(fmt.Sprintf("number of style columns in row %d (%d) does not match range size (%d)", i, len(styleRow), rangeColumnSize)), nil 215 | } 216 | 217 | for j, style := range styleRow { 218 | cell, err := excelize.CoordinatesToCellName(startCol+j, startRow+i) 219 | if err != nil { 220 | return nil, err 221 | } 222 | if style != nil { 223 | if err := worksheet.SetCellStyle(cell, style); err != nil { 224 | return nil, fmt.Errorf("failed to set style for cell %s: %w", cell, err) 225 | } 226 | } 227 | } 228 | } 229 | 230 | if err := workbook.Save(); err != nil { 231 | return nil, err 232 | } 233 | 234 | // Create response HTML 235 | html := "<h2>Formatted Range</h2>\n" 236 | html += fmt.Sprintf("<p>Successfully applied styles to range %s in sheet %s</p>\n", rangeStr, sheetName) 237 | html += "<h2>Metadata</h2>\n" 238 | html += "<ul>\n" 239 | html += fmt.Sprintf("<li>backend: %s</li>\n", workbook.GetBackendName()) 240 | html += fmt.Sprintf("<li>sheet name: %s</li>\n", sheetName) 241 | html += fmt.Sprintf("<li>formatted range: %s</li>\n", rangeStr) 242 | html += fmt.Sprintf("<li>cells processed: %d</li>\n", (endRow-startRow+1)*(endCol-startCol+1)) 243 | html += "</ul>\n" 244 | html += "<h2>Notice</h2>\n" 245 | html += "<p>Cell styles applied successfully.</p>\n" 246 | 247 | return mcp.NewToolResultText(html), nil 248 | } 249 | ``` -------------------------------------------------------------------------------- /internal/excel/excel.go: -------------------------------------------------------------------------------- ```go 1 | package excel 2 | 3 | import ( 4 | "github.com/xuri/excelize/v2" 5 | ) 6 | 7 | type Excel interface { 8 | // GetBackendName returns the backend used to manipulate the Excel file. 9 | GetBackendName() string 10 | // GetSheets returns a list of all worksheets in the Excel file. 11 | GetSheets() ([]Worksheet, error) 12 | // FindSheet finds a sheet by its name and returns a Worksheet. 13 | FindSheet(sheetName string) (Worksheet, error) 14 | // CreateNewSheet creates a new sheet with the specified name. 15 | CreateNewSheet(sheetName string) error 16 | // CopySheet copies a sheet from one to another. 17 | CopySheet(srcSheetName, destSheetName string) error 18 | // Save saves the Excel file. 19 | Save() error 20 | } 21 | 22 | type Worksheet interface { 23 | // Release releases the worksheet resources. 24 | Release() 25 | // Name returns the name of the worksheet. 26 | Name() (string, error) 27 | // GetTable returns a tables in this worksheet. 28 | GetTables() ([]Table, error) 29 | // GetPivotTable returns a pivot tables in this worksheet. 30 | GetPivotTables() ([]PivotTable, error) 31 | // SetValue sets a value in the specified cell. 32 | SetValue(cell string, value any) error 33 | // SetFormula sets a formula in the specified cell. 34 | SetFormula(cell string, formula string) error 35 | // GetValue gets the value from the specified cell. 36 | GetValue(cell string) (string, error) 37 | // GetFormula gets the formula from the specified cell. 38 | GetFormula(cell string) (string, error) 39 | // GetDimention gets the dimension of the worksheet. 40 | GetDimention() (string, error) 41 | // GetPagingStrategy returns the paging strategy for the worksheet. 42 | // The pageSize parameter is used to determine the max size of each page. 43 | GetPagingStrategy(pageSize int) (PagingStrategy, error) 44 | // CapturePicture returns base64 encoded image data of the specified range. 45 | CapturePicture(captureRange string) (string, error) 46 | // AddTable adds a table to this worksheet. 47 | AddTable(tableRange, tableName string) error 48 | // GetCellStyle gets style information for the specified cell. 49 | GetCellStyle(cell string) (*CellStyle, error) 50 | // SetCellStyle sets style for the specified cell. 51 | SetCellStyle(cell string, style *CellStyle) error 52 | } 53 | 54 | type Table struct { 55 | Name string 56 | Range string 57 | } 58 | 59 | type PivotTable struct { 60 | Name string 61 | Range string 62 | } 63 | 64 | type CellStyle struct { 65 | Border []Border `yaml:"border,omitempty"` 66 | Font *FontStyle `yaml:"font,omitempty"` 67 | Fill *FillStyle `yaml:"fill,omitempty"` 68 | NumFmt *string `yaml:"numFmt,omitempty"` 69 | DecimalPlaces *int `yaml:"decimalPlaces,omitempty"` 70 | } 71 | 72 | type Border struct { 73 | Type BorderType `yaml:"type"` 74 | Style BorderStyle `yaml:"style,omitempty"` 75 | Color string `yaml:"color,omitempty"` 76 | } 77 | 78 | type FontStyle struct { 79 | Bold *bool `yaml:"bold,omitempty"` 80 | Italic *bool `yaml:"italic,omitempty"` 81 | Underline *FontUnderline `yaml:"underline,omitempty"` 82 | Size *int `yaml:"size,omitempty"` 83 | Strike *bool `yaml:"strike,omitempty"` 84 | Color *string `yaml:"color,omitempty"` 85 | VertAlign *FontVertAlign `yaml:"vertAlign,omitempty"` 86 | } 87 | 88 | type FillStyle struct { 89 | Type FillType `yaml:"type,omitempty"` 90 | Pattern FillPattern `yaml:"pattern,omitempty"` 91 | Color []string `yaml:"color,omitempty"` 92 | Shading *FillShading `yaml:"shading,omitempty"` 93 | } 94 | 95 | // OpenFile opens an Excel file and returns an Excel interface. 96 | // It first tries to open the file using OLE automation, and if that fails, 97 | // it tries to using the excelize library. 98 | func OpenFile(absoluteFilePath string) (Excel, func(), error) { 99 | ole, releaseFn, err := NewExcelOle(absoluteFilePath) 100 | if err == nil { 101 | return ole, releaseFn, nil 102 | } 103 | // If OLE fails, try Excelize 104 | workbook, err := excelize.OpenFile(absoluteFilePath) 105 | if err != nil { 106 | return nil, func() {}, err 107 | } 108 | excelize := NewExcelizeExcel(workbook) 109 | return excelize, func() { 110 | workbook.Close() 111 | }, nil 112 | } 113 | 114 | // BorderType represents border direction 115 | type BorderType string 116 | 117 | const ( 118 | BorderTypeLeft BorderType = "left" 119 | BorderTypeRight BorderType = "right" 120 | BorderTypeTop BorderType = "top" 121 | BorderTypeBottom BorderType = "bottom" 122 | BorderTypeDiagonalDown BorderType = "diagonalDown" 123 | BorderTypeDiagonalUp BorderType = "diagonalUp" 124 | ) 125 | 126 | func (b BorderType) String() string { 127 | return string(b) 128 | } 129 | 130 | func (b BorderType) MarshalText() ([]byte, error) { 131 | return []byte(b.String()), nil 132 | } 133 | 134 | func BorderTypeValues() []BorderType { 135 | return []BorderType{ 136 | BorderTypeLeft, 137 | BorderTypeRight, 138 | BorderTypeTop, 139 | BorderTypeBottom, 140 | BorderTypeDiagonalDown, 141 | BorderTypeDiagonalUp, 142 | } 143 | } 144 | 145 | // BorderStyle represents border style constants 146 | type BorderStyle string 147 | 148 | const ( 149 | BorderStyleNone BorderStyle = "none" 150 | BorderStyleContinuous BorderStyle = "continuous" 151 | BorderStyleDash BorderStyle = "dash" 152 | BorderStyleDot BorderStyle = "dot" 153 | BorderStyleDouble BorderStyle = "double" 154 | BorderStyleDashDot BorderStyle = "dashDot" 155 | BorderStyleDashDotDot BorderStyle = "dashDotDot" 156 | BorderStyleSlantDashDot BorderStyle = "slantDashDot" 157 | BorderStyleMediumDashDot BorderStyle = "mediumDashDot" 158 | BorderStyleMediumDashDotDot BorderStyle = "mediumDashDotDot" 159 | ) 160 | 161 | func (b BorderStyle) String() string { 162 | return string(b) 163 | } 164 | 165 | func (b BorderStyle) MarshalText() ([]byte, error) { 166 | return []byte(b.String()), nil 167 | } 168 | 169 | func BorderStyleValues() []BorderStyle { 170 | return []BorderStyle{ 171 | BorderStyleNone, 172 | BorderStyleContinuous, 173 | BorderStyleDash, 174 | BorderStyleDot, 175 | BorderStyleDouble, 176 | BorderStyleDashDot, 177 | BorderStyleDashDotDot, 178 | BorderStyleSlantDashDot, 179 | BorderStyleMediumDashDot, 180 | BorderStyleMediumDashDotDot, 181 | } 182 | } 183 | 184 | // FontUnderline represents underline styles for font 185 | type FontUnderline string 186 | 187 | const ( 188 | FontUnderlineNone FontUnderline = "none" 189 | FontUnderlineSingle FontUnderline = "single" 190 | FontUnderlineDouble FontUnderline = "double" 191 | FontUnderlineSingleAccounting FontUnderline = "singleAccounting" 192 | FontUnderlineDoubleAccounting FontUnderline = "doubleAccounting" 193 | ) 194 | 195 | func (f FontUnderline) String() string { 196 | return string(f) 197 | } 198 | func (f FontUnderline) MarshalText() ([]byte, error) { 199 | return []byte(f.String()), nil 200 | } 201 | 202 | func FontUnderlineValues() []FontUnderline { 203 | return []FontUnderline{ 204 | FontUnderlineNone, 205 | FontUnderlineSingle, 206 | FontUnderlineDouble, 207 | FontUnderlineSingleAccounting, 208 | FontUnderlineDoubleAccounting, 209 | } 210 | } 211 | 212 | // FontVertAlign represents vertical alignment options for font styles 213 | type FontVertAlign string 214 | 215 | const ( 216 | FontVertAlignBaseline FontVertAlign = "baseline" 217 | FontVertAlignSuperscript FontVertAlign = "superscript" 218 | FontVertAlignSubscript FontVertAlign = "subscript" 219 | ) 220 | 221 | func (v FontVertAlign) String() string { 222 | return string(v) 223 | } 224 | 225 | func (v FontVertAlign) MarshalText() ([]byte, error) { 226 | return []byte(v.String()), nil 227 | } 228 | 229 | func FontVertAlignValues() []FontVertAlign { 230 | return []FontVertAlign{ 231 | FontVertAlignBaseline, 232 | FontVertAlignSuperscript, 233 | FontVertAlignSubscript, 234 | } 235 | } 236 | 237 | // FillType represents fill types for cell styles 238 | type FillType string 239 | 240 | const ( 241 | FillTypeGradient FillType = "gradient" 242 | FillTypePattern FillType = "pattern" 243 | ) 244 | 245 | func (f FillType) String() string { 246 | return string(f) 247 | } 248 | 249 | func (f FillType) MarshalText() ([]byte, error) { 250 | return []byte(f.String()), nil 251 | } 252 | 253 | func FillTypeValues() []FillType { 254 | return []FillType{ 255 | FillTypeGradient, 256 | FillTypePattern, 257 | } 258 | } 259 | 260 | // FillPattern represents fill pattern constants 261 | type FillPattern string 262 | 263 | const ( 264 | FillPatternNone FillPattern = "none" 265 | FillPatternSolid FillPattern = "solid" 266 | FillPatternMediumGray FillPattern = "mediumGray" 267 | FillPatternDarkGray FillPattern = "darkGray" 268 | FillPatternLightGray FillPattern = "lightGray" 269 | FillPatternDarkHorizontal FillPattern = "darkHorizontal" 270 | FillPatternDarkVertical FillPattern = "darkVertical" 271 | FillPatternDarkDown FillPattern = "darkDown" 272 | FillPatternDarkUp FillPattern = "darkUp" 273 | FillPatternDarkGrid FillPattern = "darkGrid" 274 | FillPatternDarkTrellis FillPattern = "darkTrellis" 275 | FillPatternLightHorizontal FillPattern = "lightHorizontal" 276 | FillPatternLightVertical FillPattern = "lightVertical" 277 | FillPatternLightDown FillPattern = "lightDown" 278 | FillPatternLightUp FillPattern = "lightUp" 279 | FillPatternLightGrid FillPattern = "lightGrid" 280 | FillPatternLightTrellis FillPattern = "lightTrellis" 281 | FillPatternGray125 FillPattern = "gray125" 282 | FillPatternGray0625 FillPattern = "gray0625" 283 | ) 284 | 285 | func (f FillPattern) String() string { 286 | return string(f) 287 | } 288 | 289 | func (f FillPattern) MarshalText() ([]byte, error) { 290 | return []byte(f.String()), nil 291 | } 292 | 293 | func FillPatternValues() []FillPattern { 294 | return []FillPattern{ 295 | FillPatternNone, 296 | FillPatternSolid, 297 | FillPatternMediumGray, 298 | FillPatternDarkGray, 299 | FillPatternLightGray, 300 | FillPatternDarkHorizontal, 301 | FillPatternDarkVertical, 302 | FillPatternDarkDown, 303 | FillPatternDarkUp, 304 | FillPatternDarkGrid, 305 | FillPatternDarkTrellis, 306 | FillPatternLightHorizontal, 307 | FillPatternLightVertical, 308 | FillPatternLightDown, 309 | FillPatternLightUp, 310 | FillPatternLightGrid, 311 | FillPatternLightTrellis, 312 | FillPatternGray125, 313 | FillPatternGray0625, 314 | } 315 | } 316 | 317 | // FillShading represents fill shading constants 318 | type FillShading string 319 | 320 | const ( 321 | FillShadingHorizontal FillShading = "horizontal" 322 | FillShadingVertical FillShading = "vertical" 323 | FillShadingDiagonalDown FillShading = "diagonalDown" 324 | FillShadingDiagonalUp FillShading = "diagonalUp" 325 | FillShadingFromCenter FillShading = "fromCenter" 326 | FillShadingFromCorner FillShading = "fromCorner" 327 | ) 328 | 329 | func (f FillShading) String() string { 330 | return string(f) 331 | } 332 | 333 | func (f FillShading) MarshalText() ([]byte, error) { 334 | return []byte(f.String()), nil 335 | } 336 | 337 | func FillShadingValues() []FillShading { 338 | return []FillShading{ 339 | FillShadingHorizontal, 340 | FillShadingVertical, 341 | FillShadingDiagonalDown, 342 | FillShadingDiagonalUp, 343 | FillShadingFromCenter, 344 | FillShadingFromCorner, 345 | } 346 | } 347 | ``` -------------------------------------------------------------------------------- /internal/tools/common.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "html" 7 | "path/filepath" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/goccy/go-yaml" 13 | "github.com/xuri/excelize/v2" 14 | 15 | "github.com/negokaz/excel-mcp-server/internal/excel" 16 | 17 | z "github.com/Oudwins/zog" 18 | ) 19 | 20 | type StyleRegistry struct { 21 | // Border styles 22 | borderStyles map[string]string // styleID -> YAML string 23 | borderHashToID map[string]string // styleHash -> styleID 24 | borderCounter int 25 | 26 | // Font styles 27 | fontStyles map[string]string // styleID -> YAML string 28 | fontHashToID map[string]string // styleHash -> styleID 29 | fontCounter int 30 | 31 | // Fill styles 32 | fillStyles map[string]string // styleID -> YAML string 33 | fillHashToID map[string]string // styleHash -> styleID 34 | fillCounter int 35 | 36 | // Number format styles 37 | numFmtStyles map[string]string // styleID -> NumFmt 38 | numFmtHashToID map[string]string // styleHash -> styleID 39 | numFmtCounter int 40 | 41 | // Decimal places styles 42 | decimalStyles map[string]string // styleID -> YAML string 43 | decimalHashToID map[string]string // styleHash -> styleID 44 | decimalCounter int 45 | } 46 | 47 | func NewStyleRegistry() *StyleRegistry { 48 | return &StyleRegistry{ 49 | borderStyles: make(map[string]string), 50 | borderHashToID: make(map[string]string), 51 | borderCounter: 0, 52 | fontStyles: make(map[string]string), 53 | fontHashToID: make(map[string]string), 54 | fontCounter: 0, 55 | fillStyles: make(map[string]string), 56 | fillHashToID: make(map[string]string), 57 | fillCounter: 0, 58 | numFmtStyles: make(map[string]string), 59 | numFmtHashToID: make(map[string]string), 60 | numFmtCounter: 0, 61 | decimalStyles: make(map[string]string), 62 | decimalHashToID: make(map[string]string), 63 | decimalCounter: 0, 64 | } 65 | } 66 | 67 | func (sr *StyleRegistry) RegisterStyle(cellStyle *excel.CellStyle) []string { 68 | if cellStyle == nil || sr.isEmptyStyle(cellStyle) { 69 | return []string{} 70 | } 71 | 72 | var styleIDs []string 73 | 74 | // Register border style 75 | if len(cellStyle.Border) > 0 { 76 | if borderID := sr.RegisterBorderStyle(cellStyle.Border); borderID != "" { 77 | styleIDs = append(styleIDs, borderID) 78 | } 79 | } 80 | 81 | // Register font style 82 | if cellStyle.Font != nil { 83 | if fontID := sr.RegisterFontStyle(cellStyle.Font); fontID != "" { 84 | styleIDs = append(styleIDs, fontID) 85 | } 86 | } 87 | 88 | // Register fill style 89 | if cellStyle.Fill != nil && cellStyle.Fill.Type != "" { 90 | if fillID := sr.RegisterFillStyle(cellStyle.Fill); fillID != "" { 91 | styleIDs = append(styleIDs, fillID) 92 | } 93 | } 94 | 95 | // Register number format style 96 | if cellStyle.NumFmt != nil && *cellStyle.NumFmt != "" { 97 | if numFmtID := sr.RegisterNumFmtStyle(*cellStyle.NumFmt); numFmtID != "" { 98 | styleIDs = append(styleIDs, numFmtID) 99 | } 100 | } 101 | 102 | // Register decimal places style 103 | if cellStyle.DecimalPlaces != nil && *cellStyle.DecimalPlaces != 0 { 104 | if decimalID := sr.RegisterDecimalStyle(*cellStyle.DecimalPlaces); decimalID != "" { 105 | styleIDs = append(styleIDs, decimalID) 106 | } 107 | } 108 | 109 | return styleIDs 110 | } 111 | 112 | func (sr *StyleRegistry) isEmptyStyle(style *excel.CellStyle) bool { 113 | if len(style.Border) > 0 || style.Font != nil || (style.NumFmt != nil && *style.NumFmt != "") || (style.DecimalPlaces != nil && *style.DecimalPlaces != 0) { 114 | return false 115 | } 116 | if style.Fill != nil && style.Fill.Type != "" { 117 | return false 118 | } 119 | return true 120 | } 121 | 122 | // calculateYamlHash calculates a hash for a YAML string 123 | func calculateYamlHash(yaml string) string { 124 | if yaml == "" { 125 | return "" 126 | } 127 | hash := md5.Sum([]byte(yaml)) 128 | return fmt.Sprintf("%x", hash)[:8] 129 | } 130 | 131 | // Individual style element registration methods 132 | func (sr *StyleRegistry) RegisterBorderStyle(borders []excel.Border) string { 133 | if len(borders) == 0 { 134 | return "" 135 | } 136 | 137 | yamlStr := convertToYAMLFlow(borders) 138 | if yamlStr == "" { 139 | return "" 140 | } 141 | 142 | styleHash := calculateYamlHash(yamlStr) 143 | if styleHash == "" { 144 | return "" 145 | } 146 | 147 | if existingID, exists := sr.borderHashToID[styleHash]; exists { 148 | return existingID 149 | } 150 | 151 | sr.borderCounter++ 152 | styleID := fmt.Sprintf("b%d", sr.borderCounter) 153 | sr.borderStyles[styleID] = yamlStr 154 | sr.borderHashToID[styleHash] = styleID 155 | 156 | return styleID 157 | } 158 | 159 | func (sr *StyleRegistry) RegisterFontStyle(font *excel.FontStyle) string { 160 | if font == nil { 161 | return "" 162 | } 163 | 164 | yamlStr := convertToYAMLFlow(font) 165 | if yamlStr == "" { 166 | return "" 167 | } 168 | 169 | styleHash := calculateYamlHash(yamlStr) 170 | if styleHash == "" { 171 | return "" 172 | } 173 | 174 | if existingID, exists := sr.fontHashToID[styleHash]; exists { 175 | return existingID 176 | } 177 | 178 | sr.fontCounter++ 179 | styleID := fmt.Sprintf("f%d", sr.fontCounter) 180 | sr.fontStyles[styleID] = yamlStr 181 | sr.fontHashToID[styleHash] = styleID 182 | 183 | return styleID 184 | } 185 | 186 | func (sr *StyleRegistry) RegisterFillStyle(fill *excel.FillStyle) string { 187 | if fill == nil || fill.Type == "" { 188 | return "" 189 | } 190 | 191 | yamlStr := convertToYAMLFlow(fill) 192 | if yamlStr == "" { 193 | return "" 194 | } 195 | 196 | styleHash := calculateYamlHash(yamlStr) 197 | if styleHash == "" { 198 | return "" 199 | } 200 | 201 | if existingID, exists := sr.fillHashToID[styleHash]; exists { 202 | return existingID 203 | } 204 | 205 | sr.fillCounter++ 206 | styleID := fmt.Sprintf("l%d", sr.fillCounter) 207 | sr.fillStyles[styleID] = yamlStr 208 | sr.fillHashToID[styleHash] = styleID 209 | 210 | return styleID 211 | } 212 | 213 | func (sr *StyleRegistry) RegisterNumFmtStyle(numFmt string) string { 214 | if numFmt == "" { 215 | return "" 216 | } 217 | 218 | styleHash := calculateYamlHash(numFmt) 219 | if styleHash == "" { 220 | return "" 221 | } 222 | 223 | if existingID, exists := sr.numFmtHashToID[styleHash]; exists { 224 | return existingID 225 | } 226 | 227 | sr.numFmtCounter++ 228 | styleID := fmt.Sprintf("n%d", sr.numFmtCounter) 229 | sr.numFmtStyles[styleID] = numFmt 230 | sr.numFmtHashToID[styleHash] = styleID 231 | 232 | return styleID 233 | } 234 | 235 | func (sr *StyleRegistry) RegisterDecimalStyle(decimal int) string { 236 | if decimal == 0 { 237 | return "" 238 | } 239 | 240 | yamlStr := convertToYAMLFlow(decimal) 241 | if yamlStr == "" { 242 | return "" 243 | } 244 | 245 | styleHash := calculateYamlHash(yamlStr) 246 | if styleHash == "" { 247 | return "" 248 | } 249 | 250 | if existingID, exists := sr.decimalHashToID[styleHash]; exists { 251 | return existingID 252 | } 253 | 254 | sr.decimalCounter++ 255 | styleID := fmt.Sprintf("d%d", sr.decimalCounter) 256 | sr.decimalStyles[styleID] = yamlStr 257 | sr.decimalHashToID[styleHash] = styleID 258 | 259 | return styleID 260 | } 261 | 262 | func (sr *StyleRegistry) GenerateStyleDefinitions() string { 263 | totalCount := len(sr.borderStyles) + len(sr.fontStyles) + len(sr.fillStyles) + len(sr.numFmtStyles) + len(sr.decimalStyles) 264 | if totalCount == 0 { 265 | return "" 266 | } 267 | 268 | var result strings.Builder 269 | result.WriteString("<h2>Style Definitions</h2>\n") 270 | result.WriteString("<div class=\"style-definitions\">\n") 271 | 272 | // Generate border style definitions 273 | result.WriteString(sr.generateStyleDefTag(sr.borderStyles, "border")) 274 | 275 | // Generate font style definitions 276 | result.WriteString(sr.generateStyleDefTag(sr.fontStyles, "font")) 277 | 278 | // Generate fill style definitions 279 | result.WriteString(sr.generateStyleDefTag(sr.fillStyles, "fill")) 280 | 281 | // Generate number format style definitions 282 | result.WriteString(sr.generateStyleDefTag(sr.numFmtStyles, "numFmt")) 283 | 284 | // Generate decimal places style definitions 285 | result.WriteString(sr.generateStyleDefTag(sr.decimalStyles, "decimalPlaces")) 286 | 287 | result.WriteString("</div>\n\n") 288 | return result.String() 289 | } 290 | 291 | func (sr *StyleRegistry) generateStyleDefTag(styles map[string]string, styleLabel string) string { 292 | if len(styles) == 0 { 293 | return "" 294 | } 295 | 296 | var styleIDs []string 297 | for styleID := range styles { 298 | styleIDs = append(styleIDs, styleID) 299 | } 300 | sortStyleIDs(styleIDs) 301 | 302 | var result strings.Builder 303 | for _, styleID := range styleIDs { 304 | yamlStr := styles[styleID] 305 | if yamlStr != "" { 306 | result.WriteString(fmt.Sprintf("<code class=\"style language-yaml\" id=\"%s\">%s: %s</code>\n", styleID, styleLabel, html.EscapeString(yamlStr))) 307 | } 308 | } 309 | return result.String() 310 | } 311 | 312 | func sortStyleIDs(styleIDs []string) { 313 | slices.SortFunc(styleIDs, func(a, b string) int { 314 | // styleID must have number suffix after prefix 315 | ai, _ := strconv.Atoi(a[1:]) 316 | bi, _ := strconv.Atoi(b[1:]) 317 | return ai - bi 318 | }) 319 | } 320 | 321 | // Common function to convert any value to YAML flow format 322 | func convertToYAMLFlow(value any) string { 323 | if value == nil { 324 | return "" 325 | } 326 | yamlBytes, err := yaml.MarshalWithOptions(value, yaml.Flow(true), yaml.OmitEmpty()) 327 | if err != nil { 328 | return "" 329 | } 330 | yamlStr := strings.TrimSpace(strings.ReplaceAll(string(yamlBytes), "\"", "")) 331 | return yamlStr 332 | } 333 | 334 | func CreateHTMLTableOfValues(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { 335 | return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { 336 | return worksheet.GetValue(cellRange) 337 | }) 338 | } 339 | 340 | func CreateHTMLTableOfFormula(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { 341 | return createHTMLTable(startCol, startRow, endCol, endRow, func(cellRange string) (string, error) { 342 | return worksheet.GetFormula(cellRange) 343 | }) 344 | } 345 | 346 | // CreateHTMLTable creates a table data in HTML format 347 | func createHTMLTable(startCol int, startRow int, endCol int, endRow int, extractor func(cellRange string) (string, error)) (*string, error) { 348 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, extractor, nil) 349 | } 350 | 351 | func CreateHTMLTableOfValuesWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { 352 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, 353 | func(cellRange string) (string, error) { 354 | return worksheet.GetValue(cellRange) 355 | }, 356 | func(cellRange string) (*excel.CellStyle, error) { 357 | return worksheet.GetCellStyle(cellRange) 358 | }) 359 | } 360 | 361 | func CreateHTMLTableOfFormulaWithStyle(worksheet excel.Worksheet, startCol int, startRow int, endCol int, endRow int) (*string, error) { 362 | return createHTMLTableWithStyle(startCol, startRow, endCol, endRow, 363 | func(cellRange string) (string, error) { 364 | return worksheet.GetFormula(cellRange) 365 | }, 366 | func(cellRange string) (*excel.CellStyle, error) { 367 | return worksheet.GetCellStyle(cellRange) 368 | }) 369 | } 370 | 371 | 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) { 372 | registry := NewStyleRegistry() 373 | 374 | // データとスタイルを収集 375 | var result strings.Builder 376 | result.WriteString("<table>\n<tr><th></th>") 377 | 378 | // 列アドレスの出力 379 | for col := startCol; col <= endCol; col++ { 380 | name, _ := excelize.ColumnNumberToName(col) 381 | result.WriteString(fmt.Sprintf("<th>%s</th>", name)) 382 | } 383 | result.WriteString("</tr>\n") 384 | 385 | // データの出力とスタイル登録 386 | for row := startRow; row <= endRow; row++ { 387 | result.WriteString("<tr>") 388 | result.WriteString(fmt.Sprintf("<th>%d</th>", row)) 389 | 390 | for col := startCol; col <= endCol; col++ { 391 | axis, _ := excelize.CoordinatesToCellName(col, row) 392 | value, _ := extractor(axis) 393 | 394 | var tdTag string 395 | if styleExtractor != nil { 396 | cellStyle, err := styleExtractor(axis) 397 | if err == nil && cellStyle != nil { 398 | styleIDs := registry.RegisterStyle(cellStyle) 399 | if len(styleIDs) > 0 { 400 | tdTag = fmt.Sprintf("<td style-ref=\"%s\">", strings.Join(styleIDs, " ")) 401 | } else { 402 | tdTag = "<td>" 403 | } 404 | } else { 405 | tdTag = "<td>" 406 | } 407 | } else { 408 | tdTag = "<td>" 409 | } 410 | 411 | result.WriteString(fmt.Sprintf("%s%s</td>", tdTag, strings.ReplaceAll(html.EscapeString(value), "\n", "<br>"))) 412 | } 413 | result.WriteString("</tr>\n") 414 | } 415 | 416 | result.WriteString("</table>") 417 | 418 | // スタイル定義とテーブルを結合 419 | var finalResult strings.Builder 420 | styleDefinitions := registry.GenerateStyleDefinitions() 421 | if styleDefinitions != "" { 422 | finalResult.WriteString(styleDefinitions) 423 | } 424 | 425 | finalResult.WriteString("<h2>Sheet Data</h2>\n") 426 | finalResult.WriteString(result.String()) 427 | 428 | finalResultStr := finalResult.String() 429 | return &finalResultStr, nil 430 | } 431 | 432 | func AbsolutePathTest() z.Test[*string] { 433 | return z.Test[*string]{ 434 | Func: func(path *string, ctx z.Ctx) { 435 | if !filepath.IsAbs(*path) { 436 | ctx.AddIssue(ctx.Issue().SetMessage(fmt.Sprintf("Path '%s' is not absolute", *path))) 437 | } 438 | }, 439 | } 440 | } 441 | ``` -------------------------------------------------------------------------------- /internal/excel/excel_excelize.go: -------------------------------------------------------------------------------- ```go 1 | package excel 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/xuri/excelize/v2" 10 | ) 11 | 12 | type ExcelizeExcel struct { 13 | file *excelize.File 14 | } 15 | 16 | func NewExcelizeExcel(file *excelize.File) Excel { 17 | return &ExcelizeExcel{file: file} 18 | } 19 | 20 | func (e *ExcelizeExcel) GetBackendName() string { 21 | return "excelize" 22 | } 23 | 24 | func (e *ExcelizeExcel) FindSheet(sheetName string) (Worksheet, error) { 25 | index, err := e.file.GetSheetIndex(sheetName) 26 | if err != nil { 27 | return nil, fmt.Errorf("sheet not found: %s", sheetName) 28 | } 29 | if index < 0 { 30 | return nil, fmt.Errorf("sheet not found: %s", sheetName) 31 | } 32 | return &ExcelizeWorksheet{file: e.file, sheetName: sheetName}, nil 33 | } 34 | 35 | func (e *ExcelizeExcel) CreateNewSheet(sheetName string) error { 36 | _, err := e.file.NewSheet(sheetName) 37 | if err != nil { 38 | return fmt.Errorf("failed to create new sheet: %w", err) 39 | } 40 | return nil 41 | } 42 | 43 | func (e *ExcelizeExcel) CopySheet(srcSheetName string, destSheetName string) error { 44 | srcIndex, err := e.file.GetSheetIndex(srcSheetName) 45 | if srcIndex < 0 { 46 | return fmt.Errorf("source sheet not found: %s", srcSheetName) 47 | } 48 | if err != nil { 49 | return err 50 | } 51 | destIndex, err := e.file.NewSheet(destSheetName) 52 | if err != nil { 53 | return fmt.Errorf("failed to create destination sheet: %w", err) 54 | } 55 | if err := e.file.CopySheet(srcIndex, destIndex); err != nil { 56 | return fmt.Errorf("failed to copy sheet: %w", err) 57 | } 58 | srcNext := e.file.GetSheetList()[srcIndex+1] 59 | if srcNext != srcSheetName { 60 | e.file.MoveSheet(destSheetName, srcNext) 61 | } 62 | return nil 63 | } 64 | 65 | func (e *ExcelizeExcel) GetSheets() ([]Worksheet, error) { 66 | sheetList := e.file.GetSheetList() 67 | worksheets := make([]Worksheet, len(sheetList)) 68 | for i, sheetName := range sheetList { 69 | worksheets[i] = &ExcelizeWorksheet{file: e.file, sheetName: sheetName} 70 | } 71 | return worksheets, nil 72 | } 73 | 74 | // SaveExcelize saves the Excel file to the specified path. 75 | // Excelize's Save method restricts the file path length to 207 characters, 76 | // but since this limitation has been relaxed in some environments, 77 | // we ignore this restriction. 78 | // https://github.com/qax-os/excelize/blob/v2.9.0/file.go#L71-L73 79 | func (w *ExcelizeExcel) Save() error { 80 | file, err := os.OpenFile(filepath.Clean(w.file.Path), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, os.ModePerm) 81 | if err != nil { 82 | return err 83 | } 84 | defer file.Close() 85 | return w.file.Write(file) 86 | } 87 | 88 | type ExcelizeWorksheet struct { 89 | file *excelize.File 90 | sheetName string 91 | } 92 | 93 | func (w *ExcelizeWorksheet) Release() { 94 | // No resources to release in excelize 95 | } 96 | 97 | func (w *ExcelizeWorksheet) Name() (string, error) { 98 | return w.sheetName, nil 99 | } 100 | 101 | func (w *ExcelizeWorksheet) GetTables() ([]Table, error) { 102 | tables, err := w.file.GetTables(w.sheetName) 103 | if err != nil { 104 | return nil, fmt.Errorf("failed to get tables: %w", err) 105 | } 106 | tableList := make([]Table, len(tables)) 107 | for i, table := range tables { 108 | tableList[i] = Table{ 109 | Name: table.Name, 110 | Range: NormalizeRange(table.Range), 111 | } 112 | } 113 | return tableList, nil 114 | } 115 | 116 | func (w *ExcelizeWorksheet) GetPivotTables() ([]PivotTable, error) { 117 | pivotTables, err := w.file.GetPivotTables(w.sheetName) 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to get pivot tables: %w", err) 120 | } 121 | pivotTableList := make([]PivotTable, len(pivotTables)) 122 | for i, pivotTable := range pivotTables { 123 | pivotTableList[i] = PivotTable{ 124 | Name: pivotTable.Name, 125 | Range: NormalizeRange(pivotTable.PivotTableRange), 126 | } 127 | } 128 | return pivotTableList, nil 129 | } 130 | 131 | func (w *ExcelizeWorksheet) SetValue(cell string, value any) error { 132 | if err := w.file.SetCellValue(w.sheetName, cell, value); err != nil { 133 | return err 134 | } 135 | if err := w.updateDimension(cell); err != nil { 136 | return fmt.Errorf("failed to update dimension: %w", err) 137 | } 138 | return nil 139 | } 140 | 141 | func (w *ExcelizeWorksheet) SetFormula(cell string, formula string) error { 142 | if err := w.file.SetCellFormula(w.sheetName, cell, formula); err != nil { 143 | return err 144 | } 145 | if err := w.updateDimension(cell); err != nil { 146 | return fmt.Errorf("failed to update dimension: %w", err) 147 | } 148 | return nil 149 | } 150 | 151 | func (w *ExcelizeWorksheet) GetValue(cell string) (string, error) { 152 | value, err := w.file.GetCellValue(w.sheetName, cell) 153 | if err != nil { 154 | return "", err 155 | } 156 | if value == "" { 157 | // try to get calculated value 158 | formula, err := w.file.GetCellFormula(w.sheetName, cell) 159 | if err != nil { 160 | return "", fmt.Errorf("failed to get formula: %w", err) 161 | } 162 | if formula != "" { 163 | return w.file.CalcCellValue(w.sheetName, cell) 164 | } 165 | } 166 | return value, nil 167 | } 168 | 169 | func (w *ExcelizeWorksheet) GetFormula(cell string) (string, error) { 170 | formula, err := w.file.GetCellFormula(w.sheetName, cell) 171 | if err != nil { 172 | return "", fmt.Errorf("failed to get formula: %w", err) 173 | } 174 | if formula == "" { 175 | // fallback 176 | return w.GetValue(cell) 177 | } 178 | if !strings.HasPrefix(formula, "=") { 179 | formula = "=" + formula 180 | } 181 | return formula, nil 182 | } 183 | 184 | func (w *ExcelizeWorksheet) GetDimention() (string, error) { 185 | return w.file.GetSheetDimension(w.sheetName) 186 | } 187 | 188 | func (w *ExcelizeWorksheet) GetPagingStrategy(pageSize int) (PagingStrategy, error) { 189 | return NewExcelizeFixedSizePagingStrategy(pageSize, w) 190 | } 191 | 192 | func (w *ExcelizeWorksheet) CapturePicture(captureRange string) (string, error) { 193 | return "", fmt.Errorf("CapturePicture is not supported in Excelize") 194 | } 195 | 196 | func (w *ExcelizeWorksheet) AddTable(tableRange, tableName string) error { 197 | enable := true 198 | if err := w.file.AddTable(w.sheetName, &excelize.Table{ 199 | Range: tableRange, 200 | Name: tableName, 201 | StyleName: "TableStyleMedium2", 202 | ShowColumnStripes: true, 203 | ShowFirstColumn: false, 204 | ShowHeaderRow: &enable, 205 | ShowLastColumn: false, 206 | ShowRowStripes: &enable, 207 | }); err != nil { 208 | return err 209 | } 210 | return nil 211 | } 212 | 213 | func (w *ExcelizeWorksheet) GetCellStyle(cell string) (*CellStyle, error) { 214 | styleID, err := w.file.GetCellStyle(w.sheetName, cell) 215 | if err != nil { 216 | return nil, fmt.Errorf("failed to get cell style: %w", err) 217 | } 218 | 219 | style, err := w.file.GetStyle(styleID) 220 | if err != nil { 221 | return nil, fmt.Errorf("failed to get style details: %w", err) 222 | } 223 | 224 | return convertExcelizeStyleToCellStyle(style), nil 225 | } 226 | 227 | func (w *ExcelizeWorksheet) SetCellStyle(cell string, style *CellStyle) error { 228 | excelizeStyle := convertCellStyleToExcelizeStyle(style) 229 | 230 | styleID, err := w.file.NewStyle(excelizeStyle) 231 | if err != nil { 232 | return fmt.Errorf("failed to create style: %w", err) 233 | } 234 | 235 | if err := w.file.SetCellStyle(w.sheetName, cell, cell, styleID); err != nil { 236 | return fmt.Errorf("failed to set cell style: %w", err) 237 | } 238 | 239 | return nil 240 | } 241 | 242 | func convertCellStyleToExcelizeStyle(style *CellStyle) *excelize.Style { 243 | result := &excelize.Style{} 244 | 245 | // Border 246 | if len(style.Border) > 0 { 247 | borders := make([]excelize.Border, len(style.Border)) 248 | for i, border := range style.Border { 249 | excelizeBorder := excelize.Border{ 250 | Type: border.Type.String(), 251 | } 252 | if border.Color != "" { 253 | excelizeBorder.Color = strings.TrimPrefix(border.Color, "#") 254 | } 255 | excelizeBorder.Style = borderStyleNameToInt(border.Style) 256 | borders[i] = excelizeBorder 257 | } 258 | result.Border = borders 259 | } 260 | 261 | // Font 262 | if style.Font != nil { 263 | font := &excelize.Font{} 264 | if style.Font.Bold != nil { 265 | font.Bold = *style.Font.Bold 266 | } 267 | if style.Font.Italic != nil { 268 | font.Italic = *style.Font.Italic 269 | } 270 | if style.Font.Underline != nil { 271 | font.Underline = style.Font.Underline.String() 272 | } 273 | if style.Font.Size != nil && *style.Font.Size > 0 { 274 | font.Size = float64(*style.Font.Size) 275 | } 276 | if style.Font.Strike != nil { 277 | font.Strike = *style.Font.Strike 278 | } 279 | if style.Font.Color != nil && *style.Font.Color != "" { 280 | font.Color = strings.TrimPrefix(*style.Font.Color, "#") 281 | } 282 | if style.Font.VertAlign != nil { 283 | font.VertAlign = style.Font.VertAlign.String() 284 | } 285 | result.Font = font 286 | } 287 | 288 | // Fill 289 | if style.Fill != nil { 290 | fill := excelize.Fill{} 291 | if style.Fill.Type != "" { 292 | fill.Type = style.Fill.Type.String() 293 | } 294 | fill.Pattern = fillPatternNameToInt(style.Fill.Pattern) 295 | if len(style.Fill.Color) > 0 { 296 | colors := make([]string, len(style.Fill.Color)) 297 | for i, color := range style.Fill.Color { 298 | colors[i] = strings.TrimPrefix(color, "#") 299 | } 300 | fill.Color = colors 301 | } 302 | if style.Fill.Shading != nil { 303 | fill.Shading = fillShadingNameToInt(*style.Fill.Shading) 304 | } 305 | result.Fill = fill 306 | } 307 | 308 | // NumFmt 309 | if style.NumFmt != nil && *style.NumFmt != "" { 310 | result.CustomNumFmt = style.NumFmt 311 | } 312 | 313 | // DecimalPlaces 314 | if style.DecimalPlaces != nil && *style.DecimalPlaces > 0 { 315 | result.DecimalPlaces = style.DecimalPlaces 316 | } 317 | 318 | return result 319 | } 320 | 321 | func convertExcelizeStyleToCellStyle(style *excelize.Style) *CellStyle { 322 | result := &CellStyle{} 323 | 324 | // Border 325 | if len(style.Border) > 0 { 326 | var borders []Border 327 | for _, border := range style.Border { 328 | borderStyle := Border{ 329 | Type: BorderType(border.Type), 330 | } 331 | if border.Color != "" { 332 | borderStyle.Color = "#" + strings.ToUpper(border.Color) 333 | } 334 | if border.Style != 0 { 335 | borderStyle.Style = intToBorderStyleName(border.Style) 336 | } 337 | borders = append(borders, borderStyle) 338 | } 339 | if len(borders) > 0 { 340 | result.Border = borders 341 | } 342 | } 343 | 344 | // Font 345 | if style.Font != nil { 346 | font := &FontStyle{} 347 | if style.Font.Bold { 348 | font.Bold = &style.Font.Bold 349 | } 350 | if style.Font.Italic { 351 | font.Italic = &style.Font.Italic 352 | } 353 | if style.Font.Underline != "" { 354 | underline := FontUnderline(style.Font.Underline) 355 | font.Underline = &underline 356 | } 357 | if style.Font.Size > 0 { 358 | size := int(style.Font.Size) 359 | font.Size = &size 360 | } 361 | if style.Font.Strike { 362 | font.Strike = &style.Font.Strike 363 | } 364 | if style.Font.Color != "" { 365 | color := "#" + strings.ToUpper(style.Font.Color) 366 | font.Color = &color 367 | } 368 | if style.Font.VertAlign != "" { 369 | vertAlign := FontVertAlign(style.Font.VertAlign) 370 | font.VertAlign = &vertAlign 371 | } 372 | if font.Bold != nil || font.Italic != nil || font.Underline != nil || font.Size != nil || font.Strike != nil || font.Color != nil || font.VertAlign != nil { 373 | result.Font = font 374 | } 375 | } 376 | 377 | // Fill 378 | if style.Fill.Type != "" || style.Fill.Pattern != 0 || len(style.Fill.Color) > 0 { 379 | fill := &FillStyle{} 380 | if style.Fill.Type != "" { 381 | fill.Type = FillType(style.Fill.Type) 382 | } 383 | if style.Fill.Pattern != 0 { 384 | fill.Pattern = intToFillPatternName(style.Fill.Pattern) 385 | } 386 | if len(style.Fill.Color) > 0 { 387 | var colors []string 388 | for _, color := range style.Fill.Color { 389 | if color != "" { 390 | colors = append(colors, "#"+strings.ToUpper(color)) 391 | } 392 | } 393 | if len(colors) > 0 { 394 | fill.Color = colors 395 | } 396 | } 397 | if style.Fill.Shading != 0 { 398 | shading := intToFillShadingName(style.Fill.Shading) 399 | fill.Shading = &shading 400 | } 401 | if fill.Type != "" || fill.Pattern != FillPatternNone || len(fill.Color) > 0 || fill.Shading != nil { 402 | result.Fill = fill 403 | } 404 | } 405 | 406 | // NumFmt 407 | if style.CustomNumFmt != nil && *style.CustomNumFmt != "" { 408 | result.NumFmt = style.CustomNumFmt 409 | } 410 | 411 | // DecimalPlaces 412 | if style.DecimalPlaces != nil && *style.DecimalPlaces != 0 { 413 | result.DecimalPlaces = style.DecimalPlaces 414 | } 415 | 416 | return result 417 | } 418 | 419 | func intToBorderStyleName(style int) BorderStyle { 420 | styles := map[int]BorderStyle{ 421 | 0: BorderStyleNone, 422 | 1: BorderStyleContinuous, 423 | 2: BorderStyleContinuous, 424 | 3: BorderStyleDash, 425 | 4: BorderStyleDot, 426 | 5: BorderStyleContinuous, 427 | 6: BorderStyleDouble, 428 | 7: BorderStyleContinuous, 429 | 8: BorderStyleDashDot, 430 | 9: BorderStyleDashDotDot, 431 | 10: BorderStyleSlantDashDot, 432 | 11: BorderStyleContinuous, 433 | 12: BorderStyleMediumDashDot, 434 | 13: BorderStyleMediumDashDotDot, 435 | } 436 | if name, exists := styles[style]; exists { 437 | return name 438 | } 439 | return BorderStyleContinuous 440 | } 441 | 442 | func intToFillPatternName(pattern int) FillPattern { 443 | patterns := map[int]FillPattern{ 444 | 0: FillPatternNone, 445 | 1: FillPatternSolid, 446 | 2: FillPatternMediumGray, 447 | 3: FillPatternDarkGray, 448 | 4: FillPatternLightGray, 449 | 5: FillPatternDarkHorizontal, 450 | 6: FillPatternDarkVertical, 451 | 7: FillPatternDarkDown, 452 | 8: FillPatternDarkUp, 453 | 9: FillPatternDarkGrid, 454 | 10: FillPatternDarkTrellis, 455 | 11: FillPatternLightHorizontal, 456 | 12: FillPatternLightVertical, 457 | 13: FillPatternLightDown, 458 | 14: FillPatternLightUp, 459 | 15: FillPatternLightGrid, 460 | 16: FillPatternLightTrellis, 461 | 17: FillPatternGray125, 462 | 18: FillPatternGray0625, 463 | } 464 | if name, exists := patterns[pattern]; exists { 465 | return name 466 | } 467 | return FillPatternNone 468 | } 469 | 470 | func intToFillShadingName(shading int) FillShading { 471 | shadings := map[int]FillShading{ 472 | 0: FillShadingHorizontal, 473 | 1: FillShadingVertical, 474 | 2: FillShadingDiagonalDown, 475 | 3: FillShadingDiagonalUp, 476 | 4: FillShadingFromCenter, 477 | 5: FillShadingFromCorner, 478 | } 479 | if name, exists := shadings[shading]; exists { 480 | return name 481 | } 482 | return FillShadingHorizontal 483 | } 484 | 485 | func borderStyleNameToInt(style BorderStyle) int { 486 | styles := map[BorderStyle]int{ 487 | BorderStyleNone: 0, 488 | BorderStyleContinuous: 1, 489 | BorderStyleDash: 3, 490 | BorderStyleDot: 4, 491 | BorderStyleDouble: 6, 492 | BorderStyleDashDot: 8, 493 | BorderStyleDashDotDot: 9, 494 | BorderStyleSlantDashDot: 10, 495 | BorderStyleMediumDashDot: 12, 496 | BorderStyleMediumDashDotDot: 13, 497 | } 498 | if value, exists := styles[style]; exists { 499 | return value 500 | } 501 | return 1 502 | } 503 | 504 | func fillPatternNameToInt(pattern FillPattern) int { 505 | patterns := map[FillPattern]int{ 506 | FillPatternNone: 0, 507 | FillPatternSolid: 1, 508 | FillPatternMediumGray: 2, 509 | FillPatternDarkGray: 3, 510 | FillPatternLightGray: 4, 511 | FillPatternDarkHorizontal: 5, 512 | FillPatternDarkVertical: 6, 513 | FillPatternDarkDown: 7, 514 | FillPatternDarkUp: 8, 515 | FillPatternDarkGrid: 9, 516 | FillPatternDarkTrellis: 10, 517 | FillPatternLightHorizontal: 11, 518 | FillPatternLightVertical: 12, 519 | FillPatternLightDown: 13, 520 | FillPatternLightUp: 14, 521 | FillPatternLightGrid: 15, 522 | FillPatternLightTrellis: 16, 523 | FillPatternGray125: 17, 524 | FillPatternGray0625: 18, 525 | } 526 | if value, exists := patterns[pattern]; exists { 527 | return value 528 | } 529 | return 0 530 | } 531 | 532 | func fillShadingNameToInt(shading FillShading) int { 533 | shadings := map[FillShading]int{ 534 | FillShadingHorizontal: 0, 535 | FillShadingVertical: 1, 536 | FillShadingDiagonalDown: 2, 537 | FillShadingDiagonalUp: 3, 538 | FillShadingFromCenter: 4, 539 | FillShadingFromCorner: 5, 540 | } 541 | if value, exists := shadings[shading]; exists { 542 | return value 543 | } 544 | return 0 545 | } 546 | 547 | // updateDimention updates the dimension of the worksheet after a cell is updated. 548 | func (w *ExcelizeWorksheet) updateDimension(updatedCell string) error { 549 | dimension, err := w.file.GetSheetDimension(w.sheetName) 550 | if err != nil { 551 | return err 552 | } 553 | startCol, startRow, endCol, endRow, err := ParseRange(dimension) 554 | if err != nil { 555 | return err 556 | } 557 | updatedCol, updatedRow, err := excelize.CellNameToCoordinates(updatedCell) 558 | if err != nil { 559 | return err 560 | } 561 | if startCol > updatedCol { 562 | startCol = updatedCol 563 | } 564 | if endCol < updatedCol { 565 | endCol = updatedCol 566 | } 567 | if startRow > updatedRow { 568 | startRow = updatedRow 569 | } 570 | if endRow < updatedRow { 571 | endRow = updatedRow 572 | } 573 | startRange, err := excelize.CoordinatesToCellName(startCol, startRow) 574 | if err != nil { 575 | return err 576 | } 577 | endRange, err := excelize.CoordinatesToCellName(endCol, endRow) 578 | if err != nil { 579 | return err 580 | } 581 | updatedDimension := fmt.Sprintf("%s:%s", startRange, endRange) 582 | return w.file.SetSheetDimension(w.sheetName, updatedDimension) 583 | } 584 | ``` -------------------------------------------------------------------------------- /internal/excel/excel_ole.go: -------------------------------------------------------------------------------- ```go 1 | package excel 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/base64" 7 | "fmt" 8 | "io" 9 | "path/filepath" 10 | "regexp" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/go-ole/go-ole" 15 | "github.com/go-ole/go-ole/oleutil" 16 | "github.com/skanehira/clipboard-image" 17 | ) 18 | 19 | type OleExcel struct { 20 | application *ole.IDispatch 21 | workbook *ole.IDispatch 22 | } 23 | 24 | type OleWorksheet struct { 25 | excel *OleExcel 26 | worksheet *ole.IDispatch 27 | } 28 | 29 | func NewExcelOle(absolutePath string) (*OleExcel, func(), error) { 30 | runtime.LockOSThread() 31 | ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) 32 | 33 | unknown, err := oleutil.GetActiveObject("Excel.Application") 34 | if err != nil { 35 | return nil, func() {}, err 36 | } 37 | excel, err := unknown.QueryInterface(ole.IID_IDispatch) 38 | if err != nil { 39 | return nil, func() {}, err 40 | } 41 | oleutil.MustPutProperty(excel, "ScreenUpdating", false) 42 | oleutil.MustPutProperty(excel, "EnableEvents", false) 43 | workbooks := oleutil.MustGetProperty(excel, "Workbooks").ToIDispatch() 44 | c := oleutil.MustGetProperty(workbooks, "Count").Val 45 | for i := 1; i <= int(c); i++ { 46 | workbook := oleutil.MustGetProperty(workbooks, "Item", i).ToIDispatch() 47 | fullName := oleutil.MustGetProperty(workbook, "FullName").ToString() 48 | name := oleutil.MustGetProperty(workbook, "Name").ToString() 49 | if strings.HasPrefix(fullName, "https:") && name == filepath.Base(absolutePath) { 50 | // If a workbook is opened through a WOPI URL, its absolute file path cannot be retrieved. 51 | // If the absolutePath is not writable, it assumes that the workbook has opened by WOPI. 52 | if FileIsNotWritable(absolutePath) { 53 | return &OleExcel{application: excel, workbook: workbook}, func() { 54 | oleutil.MustPutProperty(excel, "EnableEvents", true) 55 | oleutil.MustPutProperty(excel, "ScreenUpdating", true) 56 | workbook.Release() 57 | workbooks.Release() 58 | excel.Release() 59 | ole.CoUninitialize() 60 | runtime.UnlockOSThread() 61 | }, nil 62 | } else { 63 | // This workbook might not be specified with the absolutePath 64 | } 65 | } else if normalizePath(fullName) == normalizePath(absolutePath) { 66 | return &OleExcel{application: excel, workbook: workbook}, func() { 67 | oleutil.MustPutProperty(excel, "EnableEvents", true) 68 | oleutil.MustPutProperty(excel, "ScreenUpdating", true) 69 | workbook.Release() 70 | workbooks.Release() 71 | excel.Release() 72 | ole.CoUninitialize() 73 | runtime.UnlockOSThread() 74 | }, nil 75 | } 76 | } 77 | return nil, func() {}, fmt.Errorf("workbook not found: %s", absolutePath) 78 | } 79 | 80 | func NewExcelOleWithNewObject(absolutePath string) (*OleExcel, func(), error) { 81 | runtime.LockOSThread() 82 | ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED) 83 | 84 | unknown, err := oleutil.CreateObject("Excel.Application") 85 | if err != nil { 86 | return nil, func() {}, err 87 | } 88 | excel, err := unknown.QueryInterface(ole.IID_IDispatch) 89 | if err != nil { 90 | return nil, func() {}, err 91 | } 92 | workbooks := oleutil.MustGetProperty(excel, "Workbooks").ToIDispatch() 93 | workbook, err := oleutil.CallMethod(workbooks, "Open", absolutePath) 94 | if err != nil { 95 | return nil, func() {}, err 96 | } 97 | w := workbook.ToIDispatch() 98 | return &OleExcel{application: excel, workbook: w}, func() { 99 | w.Release() 100 | workbooks.Release() 101 | excel.Release() 102 | oleutil.CallMethod(excel, "Close") 103 | ole.CoUninitialize() 104 | runtime.UnlockOSThread() 105 | }, nil 106 | } 107 | 108 | func (o *OleExcel) GetBackendName() string { 109 | return "ole" 110 | } 111 | 112 | func (o *OleExcel) GetSheets() ([]Worksheet, error) { 113 | worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() 114 | defer worksheets.Release() 115 | 116 | count := int(oleutil.MustGetProperty(worksheets, "Count").Val) 117 | worksheetList := make([]Worksheet, count) 118 | 119 | for i := 1; i <= count; i++ { 120 | worksheet := oleutil.MustGetProperty(worksheets, "Item", i).ToIDispatch() 121 | worksheetList[i-1] = &OleWorksheet{ 122 | excel: o, 123 | worksheet: worksheet, 124 | } 125 | } 126 | return worksheetList, nil 127 | } 128 | 129 | func (o *OleExcel) FindSheet(sheetName string) (Worksheet, error) { 130 | worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() 131 | defer worksheets.Release() 132 | 133 | count := int(oleutil.MustGetProperty(worksheets, "Count").Val) 134 | 135 | for i := 1; i <= count; i++ { 136 | worksheet := oleutil.MustGetProperty(worksheets, "Item", i).ToIDispatch() 137 | name := oleutil.MustGetProperty(worksheet, "Name").ToString() 138 | 139 | if name == sheetName { 140 | return &OleWorksheet{ 141 | excel: o, 142 | worksheet: worksheet, 143 | }, nil 144 | } 145 | } 146 | 147 | return nil, fmt.Errorf("sheet not found: %s", sheetName) 148 | } 149 | 150 | func (o *OleExcel) CreateNewSheet(sheetName string) error { 151 | activeWorksheet := oleutil.MustGetProperty(o.workbook, "ActiveSheet").ToIDispatch() 152 | defer activeWorksheet.Release() 153 | activeWorksheetIndex := oleutil.MustGetProperty(activeWorksheet, "Index").Val 154 | worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() 155 | defer worksheets.Release() 156 | 157 | _, err := oleutil.CallMethod(worksheets, "Add", nil, activeWorksheet) 158 | if err != nil { 159 | return err 160 | } 161 | 162 | worksheet := oleutil.MustGetProperty(worksheets, "Item", activeWorksheetIndex+1).ToIDispatch() 163 | defer worksheet.Release() 164 | 165 | _, err = oleutil.PutProperty(worksheet, "Name", sheetName) 166 | if err != nil { 167 | return err 168 | } 169 | 170 | return nil 171 | } 172 | 173 | func (o *OleExcel) CopySheet(srcSheetName string, dstSheetName string) error { 174 | worksheets := oleutil.MustGetProperty(o.workbook, "Worksheets").ToIDispatch() 175 | defer worksheets.Release() 176 | 177 | srcSheetVariant, err := oleutil.GetProperty(worksheets, "Item", srcSheetName) 178 | if err != nil { 179 | return fmt.Errorf("faild to get sheet: %w", err) 180 | } 181 | srcSheet := srcSheetVariant.ToIDispatch() 182 | defer srcSheet.Release() 183 | srcSheetIndex := oleutil.MustGetProperty(srcSheet, "Index").Val 184 | 185 | _, err = oleutil.CallMethod(srcSheet, "Copy", nil, srcSheet) 186 | if err != nil { 187 | return err 188 | } 189 | 190 | dstSheetVariant, err := oleutil.GetProperty(worksheets, "Item", srcSheetIndex+1) 191 | if err != nil { 192 | return fmt.Errorf("failed to get copied sheet: %w", err) 193 | } 194 | dstSheet := dstSheetVariant.ToIDispatch() 195 | defer dstSheet.Release() 196 | 197 | _, err = oleutil.PutProperty(dstSheet, "Name", dstSheetName) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | return nil 203 | } 204 | 205 | func (o *OleExcel) Save() error { 206 | _, err := oleutil.CallMethod(o.workbook, "Save") 207 | if err != nil { 208 | return err 209 | } 210 | return nil 211 | } 212 | 213 | func (o *OleWorksheet) Release() { 214 | o.worksheet.Release() 215 | } 216 | 217 | func (o *OleWorksheet) Name() (string, error) { 218 | name := oleutil.MustGetProperty(o.worksheet, "Name").ToString() 219 | return name, nil 220 | } 221 | 222 | func (o *OleWorksheet) GetTables() ([]Table, error) { 223 | tables := oleutil.MustGetProperty(o.worksheet, "ListObjects").ToIDispatch() 224 | defer tables.Release() 225 | count := int(oleutil.MustGetProperty(tables, "Count").Val) 226 | tableList := make([]Table, count) 227 | for i := 1; i <= count; i++ { 228 | table := oleutil.MustGetProperty(tables, "Item", i).ToIDispatch() 229 | defer table.Release() 230 | name := oleutil.MustGetProperty(table, "Name").ToString() 231 | defer table.Release() 232 | tableRange := oleutil.MustGetProperty(table, "Range").ToIDispatch() 233 | defer tableRange.Release() 234 | tableList[i-1] = Table{ 235 | Name: name, 236 | Range: NormalizeRange(oleutil.MustGetProperty(tableRange, "Address").ToString()), 237 | } 238 | } 239 | return tableList, nil 240 | } 241 | 242 | func (o *OleWorksheet) GetPivotTables() ([]PivotTable, error) { 243 | pivotTables := oleutil.MustGetProperty(o.worksheet, "PivotTables").ToIDispatch() 244 | defer pivotTables.Release() 245 | count := int(oleutil.MustGetProperty(pivotTables, "Count").Val) 246 | pivotTableList := make([]PivotTable, count) 247 | for i := 1; i <= count; i++ { 248 | pivotTable := oleutil.MustGetProperty(pivotTables, "Item", i).ToIDispatch() 249 | defer pivotTable.Release() 250 | name := oleutil.MustGetProperty(pivotTable, "Name").ToString() 251 | pivotTableRange := oleutil.MustGetProperty(pivotTable, "TableRange1").ToIDispatch() 252 | defer pivotTableRange.Release() 253 | pivotTableList[i-1] = PivotTable{ 254 | Name: name, 255 | Range: NormalizeRange(oleutil.MustGetProperty(pivotTableRange, "Address").ToString()), 256 | } 257 | } 258 | return pivotTableList, nil 259 | } 260 | 261 | func (o *OleWorksheet) SetValue(cell string, value any) error { 262 | range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 263 | defer range_.Release() 264 | _, err := oleutil.PutProperty(range_, "Value", value) 265 | return err 266 | } 267 | 268 | func (o *OleWorksheet) SetFormula(cell string, formula string) error { 269 | range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 270 | defer range_.Release() 271 | _, err := oleutil.PutProperty(range_, "Formula", formula) 272 | return err 273 | } 274 | 275 | func (o *OleWorksheet) GetValue(cell string) (string, error) { 276 | range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 277 | defer range_.Release() 278 | value := oleutil.MustGetProperty(range_, "Text").Value() 279 | switch v := value.(type) { 280 | case string: 281 | return v, nil 282 | case nil: 283 | return "", nil 284 | default: // Handle other types as needed 285 | return "", fmt.Errorf("unsupported type: %T", v) 286 | } 287 | } 288 | 289 | func (o *OleWorksheet) GetFormula(cell string) (string, error) { 290 | range_ := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 291 | defer range_.Release() 292 | formula := oleutil.MustGetProperty(range_, "Formula").ToString() 293 | return formula, nil 294 | } 295 | 296 | func (o *OleWorksheet) GetDimention() (string, error) { 297 | range_ := oleutil.MustGetProperty(o.worksheet, "UsedRange").ToIDispatch() 298 | defer range_.Release() 299 | dimension := oleutil.MustGetProperty(range_, "Address").ToString() 300 | return NormalizeRange(dimension), nil 301 | } 302 | 303 | func (o *OleWorksheet) GetPagingStrategy(pageSize int) (PagingStrategy, error) { 304 | return NewOlePagingStrategy(1000, o) 305 | } 306 | 307 | func (o *OleWorksheet) PrintArea() (string, error) { 308 | v, err := oleutil.GetProperty(o.worksheet, "PageSetup") 309 | if err != nil { 310 | return "", err 311 | } 312 | pageSetup := v.ToIDispatch() 313 | defer pageSetup.Release() 314 | 315 | printArea := oleutil.MustGetProperty(pageSetup, "PrintArea").ToString() 316 | return printArea, nil 317 | } 318 | 319 | func (o *OleWorksheet) HPageBreaks() ([]int, error) { 320 | v, err := oleutil.GetProperty(o.worksheet, "HPageBreaks") 321 | if err != nil { 322 | return nil, err 323 | } 324 | hPageBreaks := v.ToIDispatch() 325 | defer hPageBreaks.Release() 326 | 327 | count := int(oleutil.MustGetProperty(hPageBreaks, "Count").Val) 328 | pageBreaks := make([]int, count) 329 | for i := 1; i <= count; i++ { 330 | pageBreak := oleutil.MustGetProperty(hPageBreaks, "Item", i).ToIDispatch() 331 | defer pageBreak.Release() 332 | location := oleutil.MustGetProperty(pageBreak, "Location").ToIDispatch() 333 | defer location.Release() 334 | row := oleutil.MustGetProperty(location, "Row").Val 335 | pageBreaks[i-1] = int(row) 336 | } 337 | return pageBreaks, nil 338 | } 339 | 340 | func (o *OleWorksheet) CapturePicture(captureRange string) (string, error) { 341 | r := oleutil.MustGetProperty(o.worksheet, "Range", captureRange).ToIDispatch() 342 | defer r.Release() 343 | _, err := oleutil.CallMethod( 344 | r, 345 | "CopyPicture", 346 | int(1), // xlScreen (https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.excel.xlpictureappearance?view=excel-pia) 347 | int(2), // xlBitmap (https://learn.microsoft.com/ja-jp/dotnet/api/microsoft.office.interop.excel.xlcopypictureformat?view=excel-pia) 348 | ) 349 | if err != nil { 350 | return "", err 351 | } 352 | // Read the image from the clipboard 353 | buf := new(bytes.Buffer) 354 | bufWriter := bufio.NewWriter(buf) 355 | clipboardReader, err := clipboard.ReadFromClipboard() 356 | if err != nil { 357 | return "", fmt.Errorf("failed to read from clipboard: %w", err) 358 | } 359 | if _, err := io.Copy(bufWriter, clipboardReader); err != nil { 360 | return "", fmt.Errorf("failed to copy clipboard data: %w", err) 361 | } 362 | if err := bufWriter.Flush(); err != nil { 363 | return "", fmt.Errorf("failed to flush buffer: %w", err) 364 | } 365 | return base64.StdEncoding.EncodeToString(buf.Bytes()), nil 366 | } 367 | 368 | func (o *OleWorksheet) AddTable(tableRange string, tableName string) error { 369 | tables := oleutil.MustGetProperty(o.worksheet, "ListObjects").ToIDispatch() 370 | defer tables.Release() 371 | 372 | // https://learn.microsoft.com/ja-jp/office/vba/api/excel.listobjects.add 373 | tableVar, err := oleutil.CallMethod( 374 | tables, 375 | "Add", 376 | int(1), // xlSrcRange (https://learn.microsoft.com/ja-jp/office/vba/api/excel.xllistobjectsourcetype) 377 | tableRange, 378 | nil, 379 | int(0), // xlYes (https://learn.microsoft.com/ja-jp/office/vba/api/excel.xlyesnoguess) 380 | ) 381 | if err != nil { 382 | return err 383 | } 384 | table := tableVar.ToIDispatch() 385 | defer table.Release() 386 | _, err = oleutil.PutProperty(table, "Name", tableName) 387 | if err != nil { 388 | return err 389 | } 390 | return err 391 | } 392 | 393 | func (o *OleWorksheet) GetCellStyle(cell string) (*CellStyle, error) { 394 | rng := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 395 | defer rng.Release() 396 | 397 | style := &CellStyle{} 398 | 399 | // Get Font information 400 | normalStyle := oleutil.MustGetProperty(o.excel.workbook, "Styles", "Normal").ToIDispatch() 401 | defer normalStyle.Release() 402 | normalFont := oleutil.MustGetProperty(normalStyle, "Font").ToIDispatch() 403 | defer normalFont.Release() 404 | font := oleutil.MustGetProperty(rng, "Font").ToIDispatch() 405 | defer font.Release() 406 | 407 | normalFontSize := int(oleutil.MustGetProperty(normalFont, "Size").Value().(float64)) 408 | normalFontBold := oleutil.MustGetProperty(normalFont, "Bold").Value().(bool) 409 | normalFontItalic := oleutil.MustGetProperty(normalFont, "Italic").Value().(bool) 410 | normalFontColor := oleutil.MustGetProperty(normalFont, "Color").Value().(float64) 411 | 412 | fontSize := int(oleutil.MustGetProperty(font, "Size").Value().(float64)) 413 | fontBold := oleutil.MustGetProperty(font, "Bold").Value().(bool) 414 | fontItalic := oleutil.MustGetProperty(font, "Italic").Value().(bool) 415 | fontColor := oleutil.MustGetProperty(font, "Color").Value().(float64) 416 | 417 | if fontSize != normalFontSize || fontBold != normalFontBold || fontItalic != normalFontItalic || fontColor != normalFontColor { 418 | colorStr := bgrToRgb(fontColor) 419 | style.Font = &FontStyle{ 420 | Bold: &fontBold, 421 | Italic: &fontItalic, 422 | Size: &fontSize, 423 | Color: &colorStr, 424 | } 425 | } 426 | 427 | // Get Interior (fill) information 428 | interior := oleutil.MustGetProperty(rng, "Interior").ToIDispatch() 429 | defer interior.Release() 430 | 431 | interiorPattern := excelPatternToFillPattern(oleutil.MustGetProperty(interior, "Pattern").Value().(int32)) 432 | 433 | if interiorPattern != FillPatternNone { 434 | interiorColor := oleutil.MustGetProperty(interior, "Color").Value().(float64) 435 | 436 | style.Fill = &FillStyle{ 437 | Type: "pattern", 438 | Pattern: interiorPattern, 439 | Color: []string{bgrToRgb(interiorColor)}, 440 | } 441 | } 442 | 443 | // Get Border information 444 | var borderStyles []Border 445 | 446 | // Get borders for each direction: Left(7), Top(8), Bottom(9), Right(10) 447 | borderPositions := []struct { 448 | index int 449 | position BorderType 450 | }{ 451 | {7, BorderTypeLeft}, 452 | {8, BorderTypeTop}, 453 | {9, BorderTypeBottom}, 454 | {10, BorderTypeRight}, 455 | } 456 | 457 | borders := oleutil.MustGetProperty(rng, "Borders").ToIDispatch() 458 | defer borders.Release() 459 | bordersLineStyle := oleutil.MustGetProperty(borders, "LineStyle") 460 | if bordersLineStyle.VT == ole.VT_NULL { 461 | // If Borders.LineStyle is null, the borders have different styles 462 | for _, pos := range borderPositions { 463 | border := oleutil.MustGetProperty(borders, "Item", pos.index).ToIDispatch() 464 | defer border.Release() 465 | 466 | borderLineStyle := excelBorderStyleToName(oleutil.MustGetProperty(border, "LineStyle").Value().(int32)) 467 | 468 | if borderLineStyle != BorderStyleNone { 469 | borderColor := oleutil.MustGetProperty(border, "Color").Value().(float64) 470 | borderStyle := Border{ 471 | Type: pos.position, 472 | Style: borderLineStyle, 473 | Color: bgrToRgb(borderColor), 474 | } 475 | borderStyles = append(borderStyles, borderStyle) 476 | } 477 | } 478 | } else { 479 | // If Borders.LineStyle is not null, all borders have the same style 480 | lineStyle := excelBorderStyleToName(bordersLineStyle.Value().(int32)) 481 | if lineStyle != BorderStyleNone { 482 | for _, pos := range borderPositions { 483 | border := oleutil.MustGetProperty(borders, "Item", pos.index).ToIDispatch() 484 | borderColor := oleutil.MustGetProperty(border, "Color").Value().(float64) 485 | borderStyle := Border{ 486 | Type: pos.position, 487 | Style: lineStyle, 488 | Color: bgrToRgb(borderColor), 489 | } 490 | borderStyles = append(borderStyles, borderStyle) 491 | } 492 | } 493 | } 494 | 495 | style.Border = borderStyles 496 | 497 | // Get NumberFormat information 498 | generalNumberFormat := oleutil.MustGetProperty(o.excel.application, "International", 26).Value().(string) // xlGeneralFormatName 499 | numberFormat := oleutil.MustGetProperty(rng, "NumberFormat").ToString() 500 | if numberFormat != generalNumberFormat && numberFormat != "@" { 501 | style.NumFmt = &numberFormat 502 | } 503 | 504 | // Extract decimal places from number format if it's a numeric format 505 | decimalPlaces := extractDecimalPlacesFromFormat(numberFormat) 506 | style.DecimalPlaces = &decimalPlaces 507 | 508 | return style, nil 509 | } 510 | 511 | // bgrToRgb converts BGR color format to RGB hex string 512 | func bgrToRgb(bgrColor float64) string { 513 | bgrColorInt := int32(bgrColor) 514 | // Extract RGB components from BGR format 515 | r := (bgrColorInt >> 0) & 0xFF 516 | g := (bgrColorInt >> 8) & 0xFF 517 | b := (bgrColorInt >> 16) & 0xFF 518 | return fmt.Sprintf("#%02X%02X%02X", r, g, b) 519 | } 520 | 521 | // excelBorderStyleToName converts Excel border style constant to BorderStyleName 522 | func excelBorderStyleToName(excelStyle int32) BorderStyle { 523 | switch excelStyle { 524 | case 1: // xlContinuous 525 | return BorderStyleContinuous 526 | case -4115: // xlDash 527 | return BorderStyleDash 528 | case -4118: // xlDot 529 | return BorderStyleDot 530 | case -4119: // xlDouble 531 | return BorderStyleDouble 532 | case 4: // xlDashDot 533 | return BorderStyleDashDot 534 | case 5: // xlDashDotDot 535 | return BorderStyleDashDotDot 536 | case 13: // xlSlantDashDot 537 | return BorderStyleSlantDashDot 538 | case -4142: // xlLineStyleNone 539 | return BorderStyleNone 540 | default: 541 | return BorderStyleNone 542 | } 543 | } 544 | 545 | // excelPatternToFillPattern converts Excel XlPattern constant to FillPatternName 546 | func excelPatternToFillPattern(excelPattern int32) FillPattern { 547 | switch excelPattern { 548 | case -4142: // xlPatternNone 549 | return FillPatternNone 550 | case 1: // xlPatternSolid 551 | return FillPatternSolid 552 | case -4125: // xlPatternGray75 553 | return FillPatternDarkGray 554 | case -4124: // xlPatternGray50 555 | return FillPatternMediumGray 556 | case -4126: // xlPatternGray25 557 | return FillPatternLightGray 558 | case -4121: // xlPatternGray16 559 | return FillPatternGray125 560 | case -4127: // xlPatternGray8 561 | return FillPatternGray0625 562 | case 9: // xlPatternHorizontal 563 | return FillPatternLightHorizontal 564 | case 12: // xlPatternVertical 565 | return FillPatternLightVertical 566 | case 10: // xlPatternDown 567 | return FillPatternLightDown 568 | case 11: // xlPatternUp 569 | return FillPatternLightUp 570 | case 16: // xlPatternGrid 571 | return FillPatternLightGrid 572 | case 17: // xlPatternCrissCross 573 | return FillPatternLightTrellis 574 | case 5: // xlPatternLightHorizontal 575 | return FillPatternLightHorizontal 576 | case 6: // xlPatternLightVertical 577 | return FillPatternLightVertical 578 | case 7: // xlPatternLightDown 579 | return FillPatternLightDown 580 | case 8: // xlPatternLightUp 581 | return FillPatternLightUp 582 | case 15: // xlPatternLightGrid 583 | return FillPatternLightGrid 584 | case 18: // xlPatternLightTrellis 585 | return FillPatternLightTrellis 586 | case 13: // xlPatternSemiGray75 587 | return FillPatternDarkHorizontal 588 | case 2: // xlPatternDarkHorizontal 589 | return FillPatternDarkHorizontal 590 | case 3: // xlPatternDarkVertical 591 | return FillPatternDarkVertical 592 | case 4: // xlPatternDarkDown 593 | return FillPatternDarkDown 594 | case 14: // xlPatternDarkUp 595 | return FillPatternDarkUp 596 | case -4162: // xlPatternDarkGrid 597 | return FillPatternDarkGrid 598 | case -4166: // xlPatternDarkTrellis 599 | return FillPatternDarkTrellis 600 | default: 601 | return FillPatternNone 602 | } 603 | } 604 | 605 | var extractDecimalPlacesRegexp = regexp.MustCompile(`\.([0#]+)`) 606 | 607 | // extractDecimalPlacesFromFormat extracts decimal places count from Excel number format string 608 | func extractDecimalPlacesFromFormat(format string) int { 609 | // Handle common numeric formats 610 | // Examples: "0.00" -> 2, "#,##0.000" -> 3, "0" -> 0 611 | matches := extractDecimalPlacesRegexp.FindStringSubmatch(format) 612 | if len(matches) > 1 { 613 | return len(matches[1]) 614 | } 615 | return 0 616 | } 617 | 618 | func (o *OleWorksheet) SetCellStyle(cell string, style *CellStyle) error { 619 | rng := oleutil.MustGetProperty(o.worksheet, "Range", cell).ToIDispatch() 620 | defer rng.Release() 621 | 622 | // Apply Font styles 623 | if style.Font != nil { 624 | font := oleutil.MustGetProperty(rng, "Font").ToIDispatch() 625 | defer font.Release() 626 | 627 | if style.Font.Bold != nil { 628 | oleutil.PutProperty(font, "Bold", *style.Font.Bold) 629 | } 630 | if style.Font.Italic != nil { 631 | oleutil.PutProperty(font, "Italic", *style.Font.Italic) 632 | } 633 | if style.Font.Size != nil && *style.Font.Size > 0 { 634 | oleutil.PutProperty(font, "Size", *style.Font.Size) 635 | } 636 | if style.Font.Color != nil && *style.Font.Color != "" { 637 | colorValue := rgbToBgr(*style.Font.Color) 638 | oleutil.PutProperty(font, "Color", colorValue) 639 | } 640 | if style.Font.Strike != nil && *style.Font.Strike { 641 | oleutil.PutProperty(font, "Strikethrough", true) 642 | } 643 | } 644 | 645 | // Apply Fill styles 646 | if style.Fill != nil { 647 | interior := oleutil.MustGetProperty(rng, "Interior").ToIDispatch() 648 | defer interior.Release() 649 | 650 | if style.Fill.Pattern != FillPatternNone { 651 | oleutil.PutProperty(interior, "Pattern", fillPatternToExcelPattern(style.Fill.Pattern)) 652 | } 653 | if len(style.Fill.Color) > 0 && style.Fill.Color[0] != "" { 654 | colorValue := rgbToBgr(style.Fill.Color[0]) 655 | oleutil.PutProperty(interior, "Color", colorValue) 656 | } 657 | } 658 | 659 | // Apply Border styles 660 | if len(style.Border) > 0 { 661 | borders := oleutil.MustGetProperty(rng, "Borders").ToIDispatch() 662 | defer borders.Release() 663 | 664 | for _, borderStyle := range style.Border { 665 | borderIndex := borderTypeToIndex(borderStyle.Type) 666 | if borderIndex > 0 { 667 | border := oleutil.MustGetProperty(borders, "Item", borderIndex).ToIDispatch() 668 | defer border.Release() 669 | 670 | oleutil.PutProperty(border, "LineStyle", borderStyleNameToExcel(borderStyle.Style)) 671 | if borderStyle.Color != "" { 672 | colorValue := rgbToBgr(borderStyle.Color) 673 | oleutil.PutProperty(border, "Color", colorValue) 674 | } 675 | } 676 | } 677 | } 678 | 679 | // Apply Number Format 680 | if style.NumFmt != nil && *style.NumFmt != "" { 681 | oleutil.PutProperty(rng, "NumberFormat", *style.NumFmt) 682 | } 683 | 684 | return nil 685 | } 686 | 687 | // rgbToBgr converts RGB hex string to BGR color format 688 | func rgbToBgr(rgbColor string) int32 { 689 | if len(rgbColor) != 7 || rgbColor[0] != '#' { 690 | return 0 691 | } 692 | 693 | r := hexToByte(rgbColor[1:3]) 694 | g := hexToByte(rgbColor[3:5]) 695 | b := hexToByte(rgbColor[5:7]) 696 | 697 | return int32(r) | (int32(g) << 8) | (int32(b) << 16) 698 | } 699 | 700 | // hexToByte converts hex string to byte 701 | func hexToByte(hex string) byte { 702 | var result byte 703 | for _, char := range hex { 704 | result *= 16 705 | if char >= '0' && char <= '9' { 706 | result += byte(char - '0') 707 | } else if char >= 'A' && char <= 'F' { 708 | result += byte(char - 'A' + 10) 709 | } else if char >= 'a' && char <= 'f' { 710 | result += byte(char - 'a' + 10) 711 | } 712 | } 713 | return result 714 | } 715 | 716 | // borderTypeToIndex converts border type string to Excel border index 717 | func borderTypeToIndex(borderType BorderType) int { 718 | switch borderType { 719 | case BorderTypeLeft: 720 | return 7 721 | case BorderTypeTop: 722 | return 8 723 | case BorderTypeBottom: 724 | return 9 725 | case BorderTypeRight: 726 | return 10 727 | case BorderTypeDiagonalDown: 728 | return 5 729 | case BorderTypeDiagonalUp: 730 | return 6 731 | default: 732 | return 0 733 | } 734 | } 735 | 736 | // borderStyleNameToExcel converts BorderStyleName to Excel constant 737 | func borderStyleNameToExcel(style BorderStyle) int32 { 738 | switch style { 739 | case BorderStyleContinuous: 740 | return 1 // xlContinuous 741 | case BorderStyleDash: 742 | return -4115 // xlDash 743 | case BorderStyleDot: 744 | return -4118 // xlDot 745 | case BorderStyleDouble: 746 | return -4119 // xlDouble 747 | case BorderStyleDashDot: 748 | return 4 // xlDashDot 749 | case BorderStyleDashDotDot: 750 | return 5 // xlDashDotDot 751 | case BorderStyleSlantDashDot: 752 | return 13 // xlSlantDashDot 753 | case BorderStyleNone: 754 | return -4142 // xlLineStyleNone 755 | default: 756 | return -4142 // xlLineStyleNone 757 | } 758 | } 759 | 760 | // fillPatternToExcelPattern converts FillPatternName to Excel pattern constant 761 | func fillPatternToExcelPattern(pattern FillPattern) int32 { 762 | switch pattern { 763 | case FillPatternSolid: 764 | return 1 // xlPatternSolid 765 | case FillPatternMediumGray: 766 | return -4124 // xlPatternGray50 767 | case FillPatternDarkGray: 768 | return -4125 // xlPatternGray75 769 | case FillPatternLightGray: 770 | return -4126 // xlPatternGray25 771 | case FillPatternGray125: 772 | return -4121 // xlPatternGray16 773 | case FillPatternGray0625: 774 | return -4127 // xlPatternGray8 775 | case FillPatternLightHorizontal: 776 | return 5 // xlPatternLightHorizontal 777 | case FillPatternLightVertical: 778 | return 6 // xlPatternLightVertical 779 | case FillPatternLightDown: 780 | return 7 // xlPatternLightDown 781 | case FillPatternLightUp: 782 | return 8 // xlPatternLightUp 783 | case FillPatternLightGrid: 784 | return 15 // xlPatternLightGrid 785 | case FillPatternLightTrellis: 786 | return 18 // xlPatternLightTrellis 787 | case FillPatternDarkHorizontal: 788 | return 2 // xlPatternDarkHorizontal 789 | case FillPatternDarkVertical: 790 | return 3 // xlPatternDarkVertical 791 | case FillPatternDarkDown: 792 | return 4 // xlPatternDarkDown 793 | case FillPatternDarkUp: 794 | return 14 // xlPatternDarkUp 795 | case FillPatternDarkGrid: 796 | return -4162 // xlPatternDarkGrid 797 | case FillPatternDarkTrellis: 798 | return -4166 // xlPatternDarkTrellis 799 | case FillPatternNone: 800 | return -4142 // xlPatternNone 801 | default: 802 | return -4142 // xlPatternNone 803 | } 804 | } 805 | 806 | func normalizePath(path string) string { 807 | // Normalize the volume name to uppercase 808 | vol := filepath.VolumeName(path) 809 | if vol == "" { 810 | return path 811 | } 812 | rest := path[len(vol):] 813 | return filepath.Clean(strings.ToUpper(vol) + rest) 814 | } 815 | ```