Compare commits
32 Commits
f75a47fe76
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20c75d1ca3 | |||
| 205174dfb3 | |||
| 57a225b749 | |||
| 8e93b39f0a | |||
| d910c1a817 | |||
| fc58fd59ac | |||
| af8f6bf2e1 | |||
| 8adb8c8fb0 | |||
| ff034fa97e | |||
| 438eebc73d | |||
| 873976c05a | |||
| 2883d01cc5 | |||
| 269f3b9757 | |||
| 899963a14c | |||
| c5416bf745 | |||
| bb547888bf | |||
| 670e7492bb | |||
| a742efa73a | |||
| e2a456fc9f | |||
| 9d232e5696 | |||
| 0d16ca6f1a | |||
| 51d6e97553 | |||
| f655336757 | |||
| d3fbbcfd7c | |||
| 3b05711e92 | |||
| 0e43a50ad2 | |||
| 90305551cc | |||
| c30f55d16d | |||
| 15ec810cea | |||
| 515b0100ac | |||
| ec8b46955b | |||
| ad8909d749 |
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(chmod:*)",
|
||||
"Bash(cargo build:*)",
|
||||
"Bash(rustc:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(rm:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
name: Release Checklist
|
||||
about: Checklist for preparing a new release
|
||||
title: 'Release v[VERSION]'
|
||||
labels: 'release'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Pre-release Checklist
|
||||
|
||||
- [ ] All planned features and fixes are merged
|
||||
- [ ] All tests are passing on main branch
|
||||
- [ ] Documentation is updated
|
||||
- [ ] CHANGELOG.md is updated (if maintained separately)
|
||||
- [ ] Version is updated in Cargo.toml
|
||||
- [ ] No critical security vulnerabilities in dependencies
|
||||
|
||||
## Release Process
|
||||
|
||||
- [ ] Run `./scripts/release.sh [patch|minor|major|version X.Y.Z]`
|
||||
- [ ] Verify all CI checks pass
|
||||
- [ ] Tag is created and pushed
|
||||
- [ ] GitHub release is created automatically
|
||||
- [ ] Binaries are built for all platforms
|
||||
- [ ] Crate is published to crates.io (for stable releases)
|
||||
- [ ] Docker images are pushed
|
||||
|
||||
## Post-release Tasks
|
||||
|
||||
- [ ] Verify release artifacts are available
|
||||
- [ ] Test installation from released binaries
|
||||
- [ ] Update any dependent projects
|
||||
- [ ] Announce release (if applicable)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!--
|
||||
Add release notes here:
|
||||
- New features
|
||||
- Bug fixes
|
||||
- Breaking changes
|
||||
- Performance improvements
|
||||
- Security fixes
|
||||
-->
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Test the release script (dry run)
|
||||
./scripts/release.sh patch --dry-run
|
||||
|
||||
# Run pre-release checks
|
||||
./scripts/release.sh check
|
||||
|
||||
# Create the actual release
|
||||
./scripts/release.sh patch # or minor/major/version X.Y.Z
|
||||
```
|
||||
@@ -1,438 +0,0 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
schedule:
|
||||
# Run tests daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
rust: [stable, beta, nightly]
|
||||
exclude:
|
||||
# Reduce matrix size by excluding some combinations
|
||||
- os: windows-latest
|
||||
rust: beta
|
||||
- os: windows-latest
|
||||
rust: nightly
|
||||
- os: macos-latest
|
||||
rust: beta
|
||||
include:
|
||||
# Add minimum supported Rust version
|
||||
- os: ubuntu-latest
|
||||
rust: 1.70.0
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-${{ matrix.rust }}-
|
||||
${{ runner.os }}-cargo-
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew update
|
||||
brew install pkg-config freetype jpeg libpng
|
||||
|
||||
- name: Check code formatting
|
||||
if: matrix.rust == 'stable'
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run Clippy lints (library only)
|
||||
if: matrix.rust == 'stable'
|
||||
run: cargo clippy --lib -- -D warnings
|
||||
|
||||
- name: Build project (no extra features)
|
||||
run: cargo build --verbose
|
||||
|
||||
- name: Run unit tests
|
||||
run: cargo test --verbose --lib
|
||||
|
||||
- name: Run integration tests (opt-in)
|
||||
if: contains(github.event.head_commit.message, '[integration]')
|
||||
run: cargo test --verbose --test args_tests
|
||||
|
||||
- name: Run doc tests (opt-in)
|
||||
if: contains(github.event.head_commit.message, '[full-ci]')
|
||||
run: cargo test --verbose --doc
|
||||
|
||||
- name: Test with minimal features (opt-in)
|
||||
if: contains(github.event.head_commit.message, '[full-ci]')
|
||||
run: cargo test --verbose --no-default-features --lib
|
||||
|
||||
- name: Test with all features (opt-in)
|
||||
if: contains(github.event.head_commit.message, '[full-ci]')
|
||||
run: cargo test --verbose --all-features --lib
|
||||
|
||||
security:
|
||||
name: Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ubuntu-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
|
||||
- name: Install cargo-deny
|
||||
run: cargo install cargo-deny
|
||||
|
||||
- name: Check licenses and dependencies
|
||||
run: cargo deny check
|
||||
|
||||
coverage:
|
||||
name: Code Coverage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- name: Generate coverage report (library only)
|
||||
run: |
|
||||
cargo llvm-cov --lib --workspace --lcov --output-path lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
benchmarks:
|
||||
name: Performance Benchmarks
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[bench]')
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ubuntu-cargo-bench-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run benchmarks
|
||||
run: cargo bench --all-features
|
||||
|
||||
- name: Store benchmark results
|
||||
uses: benchmark-action/github-action-benchmark@v1
|
||||
with:
|
||||
tool: 'cargo'
|
||||
output-file-path: target/criterion/reports/index.html
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
auto-push: true
|
||||
comment-on-alert: true
|
||||
alert-threshold: '200%'
|
||||
fail-on-alert: true
|
||||
|
||||
memory-safety:
|
||||
name: Memory Safety Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: rust-src
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Install Miri
|
||||
run: rustup component add miri
|
||||
|
||||
- name: Run Miri tests
|
||||
run: |
|
||||
cargo miri setup
|
||||
# Run a subset of tests with Miri (full test suite might be too slow)
|
||||
cargo miri test --lib -- --test-threads=1
|
||||
env:
|
||||
MIRIFLAGS: -Zmiri-strict-provenance
|
||||
|
||||
docker:
|
||||
if: contains(github.event.head_commit.message, '[docker]')
|
||||
name: Docker Build Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cat > Dockerfile << 'EOF'
|
||||
FROM rust:1.75 as builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libssl3 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libjpeg62-turbo \
|
||||
libpng16-16 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/
|
||||
EXPOSE 8080
|
||||
CMD ["docx-mcp"]
|
||||
EOF
|
||||
docker buildx build --tag docx-mcp:test .
|
||||
|
||||
- name: Test Docker container
|
||||
run: |
|
||||
# Start container in background
|
||||
docker run -d --name docx-mcp-test -p 8080:8080 docx-mcp:test
|
||||
sleep 10
|
||||
# Basic health check (adapt based on your server's health endpoint)
|
||||
docker logs docx-mcp-test
|
||||
docker stop docx-mcp-test
|
||||
docker rm docx-mcp-test
|
||||
|
||||
release-check:
|
||||
name: Release Readiness
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Check that release builds (library only)
|
||||
run: cargo build --release
|
||||
|
||||
- name: Verify package can be published
|
||||
run: cargo package --dry-run
|
||||
|
||||
- name: Generate documentation
|
||||
run: cargo doc --all-features --no-deps
|
||||
|
||||
- name: Check documentation links
|
||||
run: cargo doc --all-features --no-deps --open || true
|
||||
|
||||
integration:
|
||||
if: contains(github.event.head_commit.message, '[integration]')
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
# Add any services your integration tests might need
|
||||
# For example, if you need a test database or cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Run integration tests (focused)
|
||||
run: |
|
||||
export RUST_LOG=debug
|
||||
cargo test --test args_tests -- --nocapture --test-threads=1
|
||||
env:
|
||||
RUST_LOG: debug
|
||||
|
||||
stress-test:
|
||||
name: Stress Testing
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'schedule' || contains(github.event.head_commit.message, '[stress-test]')
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Build in release mode
|
||||
run: cargo build --release --all-features
|
||||
|
||||
- name: Run stress tests
|
||||
run: |
|
||||
export STRESS_TEST=1
|
||||
export RUST_LOG=info
|
||||
cargo test --release --test performance_tests -- --ignored --test-threads=1
|
||||
cargo test --release --test e2e_workflow_tests -- --ignored --test-threads=1
|
||||
|
||||
notify:
|
||||
name: Notify Results
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, security, coverage, benchmarks]
|
||||
if: always() && (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
steps:
|
||||
- name: Notify on success
|
||||
if: ${{ needs.test.result == 'success' && needs.security.result == 'success' && (needs.coverage.result == 'success' || needs.coverage.result == 'skipped') }}
|
||||
run: |
|
||||
echo "✅ All CI checks passed for main branch!"
|
||||
# Add webhook notification here if needed
|
||||
|
||||
- name: Notify on failure
|
||||
if: ${{ needs.test.result == 'failure' || needs.security.result == 'failure' || needs.coverage.result == 'failure' }}
|
||||
run: |
|
||||
echo "❌ CI checks failed for main branch!"
|
||||
# Add failure notification here if needed
|
||||
exit 1
|
||||
@@ -1,490 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., v1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
validate-release:
|
||||
name: Validate Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get-version.outputs.version }}
|
||||
tag: ${{ steps.get-version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version from tag or input
|
||||
id: get-version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=${VERSION#v}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${VERSION}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Validate version format
|
||||
run: |
|
||||
VERSION="${{ steps.get-version.outputs.version }}"
|
||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Invalid version format: $VERSION"
|
||||
echo "Expected format: X.Y.Z or X.Y.Z-suffix"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Valid version format: $VERSION"
|
||||
|
||||
- name: Check if version matches Cargo.toml
|
||||
run: |
|
||||
CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
INPUT_VERSION="${{ steps.get-version.outputs.version }}"
|
||||
if [ "$CARGO_VERSION" != "$INPUT_VERSION" ]; then
|
||||
echo "❌ Version mismatch:"
|
||||
echo " Cargo.toml: $CARGO_VERSION"
|
||||
echo " Tag/Input: $INPUT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Version matches Cargo.toml: $INPUT_VERSION"
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: validate-release
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
rust: [stable]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ matrix.rust }}
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: |
|
||||
brew update
|
||||
brew install pkg-config freetype jpeg libpng
|
||||
|
||||
- name: Run tests (library only)
|
||||
run: cargo test --verbose --lib
|
||||
|
||||
build:
|
||||
name: Build Release Artifacts
|
||||
runs-on: ${{ matrix.job.os }}
|
||||
needs: [validate-release, test]
|
||||
strategy:
|
||||
matrix:
|
||||
job:
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, use-cross: false }
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, use-cross: true }
|
||||
- { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, use-cross: true }
|
||||
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, use-cross: true }
|
||||
- { os: windows-latest, target: x86_64-pc-windows-msvc, use-cross: false }
|
||||
- { os: macos-latest, target: x86_64-apple-darwin, use-cross: false }
|
||||
- { os: macos-latest, target: aarch64-apple-darwin, use-cross: false }
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.job.target }}
|
||||
|
||||
- name: Install cross compilation tools
|
||||
if: matrix.job.use-cross
|
||||
run: |
|
||||
cargo install cross --git https://github.com/cross-rs/cross
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-${{ matrix.job.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install system dependencies (Ubuntu)
|
||||
if: matrix.job.os == 'ubuntu-latest' && !matrix.job.use-cross
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Install system dependencies (macOS)
|
||||
if: matrix.job.os == 'macos-latest'
|
||||
run: |
|
||||
brew update
|
||||
brew install pkg-config freetype jpeg libpng
|
||||
|
||||
- name: Build release binary
|
||||
run: |
|
||||
if [ "${{ matrix.job.use-cross }}" = "true" ]; then
|
||||
cross build --release --target ${{ matrix.job.target }}
|
||||
else
|
||||
cargo build --release --target ${{ matrix.job.target }}
|
||||
fi
|
||||
|
||||
- name: Prepare release archive
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ needs.validate-release.outputs.version }}"
|
||||
TARGET="${{ matrix.job.target }}"
|
||||
|
||||
# Create staging directory
|
||||
mkdir -p staging
|
||||
|
||||
# Copy binary
|
||||
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
|
||||
cp "target/${TARGET}/release/docx-mcp.exe" staging/
|
||||
BINARY="docx-mcp.exe"
|
||||
else
|
||||
cp "target/${TARGET}/release/docx-mcp" staging/
|
||||
BINARY="docx-mcp"
|
||||
fi
|
||||
|
||||
# Copy additional files
|
||||
cp README.md staging/
|
||||
cp LICENSE staging/
|
||||
|
||||
# Create archive name
|
||||
ARCHIVE="docx-mcp-${VERSION}-${TARGET}"
|
||||
|
||||
# Create archive
|
||||
cd staging
|
||||
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
|
||||
7z a "../${ARCHIVE}.zip" *
|
||||
echo "ARCHIVE=${ARCHIVE}.zip" >> $GITHUB_ENV
|
||||
else
|
||||
tar czf "../${ARCHIVE}.tar.gz" *
|
||||
echo "ARCHIVE=${ARCHIVE}.tar.gz" >> $GITHUB_ENV
|
||||
fi
|
||||
cd ..
|
||||
|
||||
# Generate checksums
|
||||
if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then
|
||||
certutil -hashfile "${ARCHIVE}.zip" SHA256 > "${ARCHIVE}.zip.sha256"
|
||||
else
|
||||
shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
|
||||
fi
|
||||
|
||||
echo "BINARY=${BINARY}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload release artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ matrix.job.target }}
|
||||
path: |
|
||||
${{ env.ARCHIVE }}
|
||||
${{ env.ARCHIVE }}.sha256
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-release, build]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find artifacts -type f -name "*.tar.gz" -o -name "*.zip" -o -name "*.sha256" | \
|
||||
xargs -I {} cp {} release-assets/
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ needs.validate-release.outputs.version }}"
|
||||
TAG="${{ needs.validate-release.outputs.tag }}"
|
||||
|
||||
# Get previous tag
|
||||
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1)
|
||||
|
||||
echo "Generating changelog from ${PREV_TAG} to ${TAG}"
|
||||
|
||||
# Generate changelog
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges ${PREV_TAG}..HEAD)
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
|
||||
# Create release notes
|
||||
cat > release-notes.md << EOF
|
||||
## What's Changed
|
||||
|
||||
${CHANGELOG}
|
||||
|
||||
## Installation
|
||||
|
||||
### Pre-built Binaries
|
||||
|
||||
Download the appropriate binary for your system:
|
||||
|
||||
- **Linux x86_64**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-gnu.tar.gz\`
|
||||
- **Linux x86_64 (musl)**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-musl.tar.gz\`
|
||||
- **Linux ARM64**: \`docx-mcp-${VERSION}-aarch64-unknown-linux-gnu.tar.gz\`
|
||||
- **macOS Intel**: \`docx-mcp-${VERSION}-x86_64-apple-darwin.tar.gz\`
|
||||
- **macOS Apple Silicon**: \`docx-mcp-${VERSION}-aarch64-apple-darwin.tar.gz\`
|
||||
- **Windows x86_64**: \`docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip\`
|
||||
|
||||
### From Source
|
||||
|
||||
\`\`\`bash
|
||||
cargo install --git https://github.com/hongkongkiwi/docx-mcp --tag ${TAG}
|
||||
\`\`\`
|
||||
|
||||
### Verification
|
||||
|
||||
All binaries are provided with SHA256 checksums for verification:
|
||||
|
||||
\`\`\`bash
|
||||
# Linux/macOS
|
||||
shasum -a 256 -c docx-mcp-${VERSION}-your-target.tar.gz.sha256
|
||||
|
||||
# Windows
|
||||
certutil -hashfile docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip SHA256
|
||||
\`\`\`
|
||||
|
||||
## Full Changelog
|
||||
|
||||
**Full Changelog**: https://github.com/hongkongkiwi/docx-mcp/compare/${PREV_TAG}...${TAG}
|
||||
EOF
|
||||
|
||||
echo "CHANGELOG_FILE=release-notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.validate-release.outputs.tag }}
|
||||
name: Release ${{ needs.validate-release.outputs.tag }}
|
||||
body_path: ${{ steps.changelog.outputs.CHANGELOG_FILE }}
|
||||
files: release-assets/*
|
||||
draft: false
|
||||
prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }}
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-crate:
|
||||
name: Publish to crates.io
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-release, create-release]
|
||||
if: ${{ !contains(needs.validate-release.outputs.version, '-') }} # Only publish stable releases
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ubuntu-cargo-publish-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Verify package
|
||||
run: cargo package --dry-run
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
docker-release:
|
||||
name: Build and Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-release, create-release]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
${{ secrets.DOCKERHUB_USERNAME && format('{0}/docx-mcp', secrets.DOCKERHUB_USERNAME) || '' }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
update-docs:
|
||||
name: Update Documentation
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-release, create-release]
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev
|
||||
|
||||
- name: Generate documentation
|
||||
run: |
|
||||
cargo doc --all-features --no-deps
|
||||
echo '<meta http-equiv="refresh" content="0; url=docx_mcp">' > target/doc/index.html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./target/doc
|
||||
cname: docs.example.com # Replace with your custom domain if you have one
|
||||
|
||||
notify-success:
|
||||
name: Notify Release Success
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-release, publish-crate, docker-release, update-docs]
|
||||
if: success()
|
||||
steps:
|
||||
- name: Success notification
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate-release.outputs.tag }} completed successfully!"
|
||||
echo "- ✅ GitHub release created"
|
||||
echo "- ✅ Binaries built for all platforms"
|
||||
echo "- ✅ Published to crates.io"
|
||||
echo "- ✅ Docker images pushed"
|
||||
echo "- ✅ Documentation updated"
|
||||
|
||||
notify-failure:
|
||||
name: Notify Release Failure
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate-release, test, build, create-release, publish-crate, docker-release, update-docs]
|
||||
if: failure()
|
||||
steps:
|
||||
- name: Failure notification
|
||||
run: |
|
||||
echo "❌ Release ${{ needs.validate-release.outputs.tag }} failed!"
|
||||
echo "Please check the workflow logs for details."
|
||||
exit 1
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
/example/MCP-Doc
|
||||
|
||||
# Rust
|
||||
target/
|
||||
Cargo.lock
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test outputs
|
||||
*.docx
|
||||
*.pdf
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
images/
|
||||
thumbnails/
|
||||
output/
|
||||
|
||||
# Temporary files
|
||||
/tmp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Documentation build
|
||||
/docs/_build/
|
||||
/docs/.doctrees/
|
||||
|
||||
# Coverage
|
||||
*.gcov
|
||||
*.gcda
|
||||
*.gcno
|
||||
coverage/
|
||||
lcov.info
|
||||
|
||||
# Profiling
|
||||
*.prof
|
||||
perf.data
|
||||
perf.data.old
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
-140
@@ -1,140 +0,0 @@
|
||||
[package]
|
||||
name = "docx-mcp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
description = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation"
|
||||
documentation = "https://docs.rs/docx-mcp"
|
||||
homepage = "https://github.com/hongkongkiwi/docx-mcp"
|
||||
repository = "https://github.com/hongkongkiwi/docx-mcp"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
keywords = ["mcp", "docx", "word", "document", "pdf"]
|
||||
categories = ["text-processing", "api-bindings", "command-line-utilities"]
|
||||
exclude = [
|
||||
"/.github/*",
|
||||
"/tests/fixtures/*",
|
||||
"/example/*",
|
||||
"/benches/*",
|
||||
"/.gitignore",
|
||||
"/deny.toml"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
# Official MCP SDK
|
||||
mcp-server = "0.1"
|
||||
mcp-core = "0.1"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# DOCX manipulation (pure Rust)
|
||||
docx-rs = "0.4"
|
||||
zip = "0.6"
|
||||
quick-xml = "0.36"
|
||||
|
||||
# Pure Rust text extraction from DOCX
|
||||
roxmltree = "0.20" # XML parsing without external deps
|
||||
|
||||
# PDF generation (pure Rust)
|
||||
printpdf = "0.7"
|
||||
lopdf = "0.34"
|
||||
rusttype = "0.9" # Font rendering in pure Rust
|
||||
|
||||
# Embedded fonts for PDF
|
||||
include-bytes-plus = "1.0"
|
||||
|
||||
# Image processing (pure Rust)
|
||||
image = { version = "0.25", features = ["png", "jpeg", "webp", "bmp", "gif"] }
|
||||
imageproc = "0.25"
|
||||
resvg = "0.44" # SVG rendering in pure Rust
|
||||
tiny-skia = "0.11" # 2D graphics in pure Rust
|
||||
usvg = "0.44" # SVG parsing
|
||||
|
||||
# HTML/Markdown to PDF (pure Rust alternatives)
|
||||
pulldown-cmark = "0.12" # Markdown parsing
|
||||
html5ever = "0.29" # HTML parsing
|
||||
comrak = "0.28" # CommonMark parsing
|
||||
|
||||
# Text extraction from DOCX
|
||||
dotext = "0.1"
|
||||
|
||||
# Template rendering (pure Rust)
|
||||
handlebars = "6.0" # Template engine
|
||||
tera = { version = "1.20", optional = true }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
ureq = { version = "2.10", features = ["tls"] }
|
||||
flate2 = { version = "1.0", features = ["rust_backend"] }
|
||||
tar = "0.4"
|
||||
sha2 = "0.10"
|
||||
|
||||
# Error handling and logging
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# File handling
|
||||
tempfile = "3.10"
|
||||
walkdir = "2.5"
|
||||
|
||||
# Additional utilities
|
||||
uuid = { version = "1.10", features = ["v4", "serde"] }
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
regex = "1.10"
|
||||
once_cell = "1.20"
|
||||
|
||||
# Command line argument parsing
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
|
||||
# Optional external tool support
|
||||
headless_chrome = { version = "1.0", optional = true }
|
||||
wkhtmltopdf = { version = "0.4", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["embedded-fonts", "pure-rust-pdf"]
|
||||
runtime-server = []
|
||||
embedded-fonts = []
|
||||
pure-rust-pdf = []
|
||||
external-tools = ["headless_chrome", "wkhtmltopdf"]
|
||||
full = ["embedded-fonts", "pure-rust-pdf", "external-tools", "tera"]
|
||||
build-bin = []
|
||||
|
||||
[build-dependencies]
|
||||
anyhow = "1.0"
|
||||
|
||||
[[bin]]
|
||||
name = "docx-mcp"
|
||||
path = "src/main.rs"
|
||||
required-features = ["build-bin"]
|
||||
|
||||
[lib]
|
||||
name = "docx_mcp"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
# Testing framework
|
||||
tokio-test = "0.4"
|
||||
assert_matches = "1.5"
|
||||
pretty_assertions = "1.4"
|
||||
rstest = "0.18"
|
||||
test-log = "0.2"
|
||||
|
||||
# Test utilities
|
||||
tempfile = "3.10"
|
||||
uuid = { version = "1.10", features = ["v4"] }
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
# Mock and fixtures
|
||||
mockito = "1.4"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
[[bench]]
|
||||
name = "docx_benchmarks"
|
||||
harness = false
|
||||
+58
-62
@@ -1,77 +1,73 @@
|
||||
# Multi-stage Docker build for docx-mcp
|
||||
FROM rust:1.75-slim as builder
|
||||
# Dockerfile for py-docx-mcp (Python MCP server) - OpenWebUI: MCP (Streamable HTTP)
|
||||
# Usage:
|
||||
# docker build -t py-docx-mcp .
|
||||
# docker run --rm -p 3000:3000 py-docx-mcp
|
||||
#
|
||||
# In OpenWebUI:
|
||||
# - Type: MCP (Streamable HTTP)
|
||||
# - URL: http://<host>:3000
|
||||
# - Auth: Bearer (if DOCX_MCP_API_KEY is set)
|
||||
#
|
||||
# Environment:
|
||||
# DOCX_MCP_API_KEY - API key (Bearer). Optional but recommended.
|
||||
# DOCX_MCP_HTTP_HOST - Bind host (default: 0.0.0.0)
|
||||
# DOCX_MCP_HTTP_PORT - Bind port (default: 3000)
|
||||
# DOCX_MCP_TEMPLATES_DIR - Templates directory (default: /templates)
|
||||
# DOCX_MCP_MAX_SIZE - Max document size in bytes (default: 104857600)
|
||||
# DOCX_MCP_MAX_DOCS - Max open documents (default: 30)
|
||||
# DOCX_MCP_SANDBOX - Enable sandbox mode (default: true)
|
||||
# DOCX_MCP_ALLOW_EXTERNAL_TOOLS - Allow external tools (default: false)
|
||||
# DOCX_MCP_ALLOW_NETWORK - Allow network access (default: false)
|
||||
|
||||
# Install system dependencies for building
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libfontconfig1-dev \
|
||||
libfreetype6-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
FROM python:3.12-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=off \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY build.rs ./
|
||||
|
||||
# Copy source code
|
||||
COPY src/ ./src/
|
||||
COPY benches/ ./benches/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
# Build the application
|
||||
RUN cargo build --release --all-features
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libssl3 \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
libjpeg62-turbo \
|
||||
libpng16-16 \
|
||||
ca-certificates \
|
||||
# System deps (for python-docx, Pillow, and optional external converters)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libjpeg62-turbo-dev \
|
||||
libpng-dev \
|
||||
libfreetype6-dev \
|
||||
libfontconfig1-dev \
|
||||
libreoffice \
|
||||
poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r docxmcp && useradd -r -g docxmcp -s /bin/bash -d /app docxmcp
|
||||
# Copy project metadata and source before installing
|
||||
COPY pyproject.toml ./
|
||||
COPY src ./src
|
||||
|
||||
# Create app directory and set ownership
|
||||
WORKDIR /app
|
||||
RUN chown -R docxmcp:docxmcp /app
|
||||
# Install Python dependencies (including this package)
|
||||
RUN pip install --upgrade pip && pip install .
|
||||
|
||||
# Copy the built binary from builder stage
|
||||
COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/docx-mcp
|
||||
RUN chmod +x /usr/local/bin/docx-mcp
|
||||
# Ensure modules in src are importable at runtime
|
||||
ENV PYTHONPATH="/app/src"
|
||||
|
||||
# Copy additional files if needed
|
||||
COPY README.md LICENSE ./
|
||||
# Create runtime dirs
|
||||
RUN mkdir -p /templates /out /tmp/py-docx-mcp
|
||||
|
||||
# Switch to non-root user
|
||||
USER docxmcp
|
||||
# Environment
|
||||
ENV DOCX_MCP_HTTP_HOST=0.0.0.0 \
|
||||
DOCX_MCP_HTTP_PORT=3000 \
|
||||
DOCX_MCP_TEMPLATES_DIR=/templates \
|
||||
DOCX_MCP_MAX_SIZE=104857600 \
|
||||
DOCX_MCP_MAX_DOCS=30 \
|
||||
DOCX_MCP_SANDBOX=true \
|
||||
DOCX_MCP_ALLOW_EXTERNAL_TOOLS=true \
|
||||
DOCX_MCP_ALLOW_NETWORK=false
|
||||
|
||||
# Create temp directory for document processing
|
||||
RUN mkdir -p /tmp/docx-mcp && chmod 755 /tmp/docx-mcp
|
||||
# Expose HTTP port (Streamable HTTP for OpenWebUI)
|
||||
EXPOSE 3000
|
||||
|
||||
# Expose default MCP port (though MCP typically uses stdin/stdout)
|
||||
EXPOSE 8080
|
||||
# Health check (ensure server module is importable)
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD python -c "from server import make_app; print('ok')" || exit 1
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD /usr/local/bin/docx-mcp --version || exit 1
|
||||
|
||||
# Set environment variables
|
||||
ENV RUST_LOG=info
|
||||
ENV DOCX_MCP_TEMP_DIR=/tmp/docx-mcp
|
||||
|
||||
# Default command
|
||||
CMD ["/usr/local/bin/docx-mcp"]
|
||||
# Default: Streamable HTTP for OpenWebUI MCP
|
||||
ENTRYPOINT ["python", "-m", "server"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 DOCX MCP Server Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
# Release Guide
|
||||
|
||||
This document describes the release process for docx-mcp.
|
||||
|
||||
## Overview
|
||||
|
||||
The release process is automated using GitHub Actions and includes:
|
||||
|
||||
- Automated testing on multiple platforms
|
||||
- Building release binaries for all supported targets
|
||||
- Publishing to crates.io
|
||||
- Creating GitHub releases with binaries
|
||||
- Building and pushing Docker images
|
||||
- Updating documentation
|
||||
|
||||
## Release Types
|
||||
|
||||
### Semantic Versioning
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/):
|
||||
|
||||
- **MAJOR**: Incompatible API changes
|
||||
- **MINOR**: New features (backwards compatible)
|
||||
- **PATCH**: Bug fixes (backwards compatible)
|
||||
|
||||
### Pre-release Versions
|
||||
|
||||
Pre-release versions can include suffixes like:
|
||||
- `1.0.0-alpha.1` - Alpha releases
|
||||
- `1.0.0-beta.1` - Beta releases
|
||||
- `1.0.0-rc.1` - Release candidates
|
||||
|
||||
## Quick Release Process
|
||||
|
||||
For most releases, use the automated release script:
|
||||
|
||||
```bash
|
||||
# Patch release (1.0.0 -> 1.0.1)
|
||||
./scripts/release.sh patch
|
||||
|
||||
# Minor release (1.0.0 -> 1.1.0)
|
||||
./scripts/release.sh minor
|
||||
|
||||
# Major release (1.0.0 -> 2.0.0)
|
||||
./scripts/release.sh major
|
||||
|
||||
# Specific version
|
||||
./scripts/release.sh version 1.5.0
|
||||
|
||||
# Pre-release
|
||||
./scripts/release.sh version 1.0.0-beta.1
|
||||
```
|
||||
|
||||
## Manual Release Process
|
||||
|
||||
If you need to create a release manually:
|
||||
|
||||
### 1. Pre-release Checks
|
||||
|
||||
```bash
|
||||
# Run all checks
|
||||
./scripts/release.sh check
|
||||
|
||||
# Or manually:
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-features
|
||||
cargo build --release --all-features
|
||||
cargo package --dry-run
|
||||
```
|
||||
|
||||
### 2. Update Version
|
||||
|
||||
Update the version in `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[package]
|
||||
version = "1.2.3"
|
||||
```
|
||||
|
||||
Update `Cargo.lock`:
|
||||
|
||||
```bash
|
||||
cargo update -p docx-mcp
|
||||
```
|
||||
|
||||
### 3. Commit and Tag
|
||||
|
||||
```bash
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "Release v1.2.3"
|
||||
git tag -a "v1.2.3" -m "Release v1.2.3"
|
||||
git push origin main
|
||||
git push origin v1.2.3
|
||||
```
|
||||
|
||||
### 4. GitHub Actions
|
||||
|
||||
The release workflow will automatically:
|
||||
|
||||
1. Validate the release
|
||||
2. Run tests on all platforms
|
||||
3. Build binaries for all targets
|
||||
4. Create GitHub release
|
||||
5. Publish to crates.io (stable releases only)
|
||||
6. Build and push Docker images
|
||||
7. Update documentation
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
Release binaries are built for:
|
||||
|
||||
- **Linux**: x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl
|
||||
- **Linux ARM**: aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl
|
||||
- **macOS**: x86_64-apple-darwin, aarch64-apple-darwin
|
||||
- **Windows**: x86_64-pc-windows-msvc
|
||||
|
||||
## Docker Images
|
||||
|
||||
Docker images are published to:
|
||||
|
||||
- GitHub Container Registry: `ghcr.io/hongkongkiwi/docx-mcp`
|
||||
- Docker Hub: `dockerhub-username/docx-mcp` (if configured)
|
||||
|
||||
Tags include:
|
||||
- `latest` - Latest stable release
|
||||
- `v1.2.3` - Specific version
|
||||
- `1.2.3` - Semantic version
|
||||
- `1.2` - Major.minor version
|
||||
- `1` - Major version
|
||||
|
||||
## Publishing to crates.io
|
||||
|
||||
Stable releases (without pre-release suffixes) are automatically published to crates.io.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Set `CARGO_REGISTRY_TOKEN` secret in GitHub repository settings
|
||||
2. Ensure you have publishing permissions for the crate
|
||||
|
||||
### Manual Publishing
|
||||
|
||||
```bash
|
||||
# Dry run
|
||||
cargo publish --dry-run
|
||||
|
||||
# Publish
|
||||
cargo publish
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Release Workflow Fails
|
||||
|
||||
1. Check the Actions tab in GitHub for detailed logs
|
||||
2. Common issues:
|
||||
- Version mismatch between tag and Cargo.toml
|
||||
- Tests failing on specific platforms
|
||||
- Missing secrets (CARGO_REGISTRY_TOKEN, DOCKERHUB credentials)
|
||||
|
||||
### Version Already Exists
|
||||
|
||||
If you need to recreate a release:
|
||||
|
||||
1. Delete the tag: `git tag -d v1.2.3 && git push origin :v1.2.3`
|
||||
2. Delete the GitHub release (if created)
|
||||
3. Create the tag again
|
||||
|
||||
### Docker Build Fails
|
||||
|
||||
1. Check if all dependencies are available in the Docker environment
|
||||
2. Verify Dockerfile syntax and build context
|
||||
3. Test locally: `docker build -t docx-mcp:test .`
|
||||
|
||||
### crates.io Publishing Fails
|
||||
|
||||
1. Verify `CARGO_REGISTRY_TOKEN` is set and valid
|
||||
2. Check if version already exists
|
||||
3. Ensure all required metadata is in Cargo.toml
|
||||
4. Run `cargo package --dry-run` to check for issues
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Signing Releases
|
||||
|
||||
Currently, releases are not cryptographically signed. Consider adding:
|
||||
|
||||
1. GPG signing of Git tags
|
||||
2. Binary signing with platform-specific tools
|
||||
3. SBOM (Software Bill of Materials) generation
|
||||
|
||||
### Supply Chain Security
|
||||
|
||||
- Dependencies are audited in CI with `cargo audit`
|
||||
- Docker images use specific base image versions
|
||||
- Build reproducibility is enhanced with Rust's deterministic builds
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Use this checklist for important releases:
|
||||
|
||||
- [ ] All planned features are implemented
|
||||
- [ ] All tests pass locally and in CI
|
||||
- [ ] Documentation is updated
|
||||
- [ ] Breaking changes are documented
|
||||
- [ ] Migration guide is provided (for major releases)
|
||||
- [ ] Security implications are reviewed
|
||||
- [ ] Performance regression tests pass
|
||||
- [ ] Cross-platform compatibility verified
|
||||
- [ ] Release notes are prepared
|
||||
|
||||
## Post-Release Tasks
|
||||
|
||||
After a release:
|
||||
|
||||
1. **Verify Installation**: Test installation from released binaries
|
||||
2. **Update Examples**: Update example configurations if needed
|
||||
3. **Notify Users**: Announce significant releases
|
||||
4. **Monitor Issues**: Watch for issues related to the new release
|
||||
5. **Update Dependencies**: Consider updating dependent projects
|
||||
|
||||
## Emergency Releases
|
||||
|
||||
For critical security fixes:
|
||||
|
||||
1. Create a hotfix branch from the affected release tag
|
||||
2. Apply minimal fix
|
||||
3. Follow expedited release process
|
||||
4. Consider yanking affected versions from crates.io if necessary
|
||||
|
||||
```bash
|
||||
# Yank a version from crates.io (if needed)
|
||||
cargo yank --version 1.2.3
|
||||
|
||||
# Un-yank if needed later
|
||||
cargo yank --version 1.2.3 --undo
|
||||
```
|
||||
|
||||
## Release Schedule
|
||||
|
||||
- **Patch releases**: As needed for bug fixes
|
||||
- **Minor releases**: Monthly or when significant features accumulate
|
||||
- **Major releases**: Annually or when breaking changes are necessary
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Open an issue for release-related problems
|
||||
- Check GitHub Actions logs for CI failures
|
||||
- Review this guide and workflow files for automation details
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,456 +0,0 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use docx_mcp::pure_converter::PureRustConverter;
|
||||
use tempfile::TempDir;
|
||||
use std::time::Duration;
|
||||
|
||||
fn setup_handler() -> (DocxHandler, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
(handler, temp_dir)
|
||||
}
|
||||
|
||||
fn bench_document_creation(c: &mut Criterion) {
|
||||
c.bench_function("create_document", |b| {
|
||||
b.iter_batched(
|
||||
|| setup_handler(),
|
||||
|(mut handler, _temp_dir)| {
|
||||
black_box(handler.create_document().unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_paragraph_addition(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("add_paragraph");
|
||||
|
||||
for paragraph_count in [1, 10, 100, 1000].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("count", paragraph_count),
|
||||
paragraph_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
(handler, doc_id, temp_dir)
|
||||
},
|
||||
|(mut handler, doc_id, _temp_dir)| {
|
||||
for i in 0..count {
|
||||
let text = format!("This is paragraph number {} with some content", i);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
black_box(doc_id)
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_styled_paragraph_addition(c: &mut Criterion) {
|
||||
c.bench_function("add_styled_paragraph", |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
let style = DocxStyle {
|
||||
font_family: Some("Arial".to_string()),
|
||||
font_size: Some(12),
|
||||
bold: Some(true),
|
||||
italic: Some(false),
|
||||
underline: Some(false),
|
||||
color: Some("#000000".to_string()),
|
||||
alignment: Some("left".to_string()),
|
||||
line_spacing: Some(1.0),
|
||||
};
|
||||
(handler, doc_id, temp_dir, style)
|
||||
},
|
||||
|(mut handler, doc_id, _temp_dir, style)| {
|
||||
black_box(handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_heading_addition(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("add_heading");
|
||||
|
||||
for level in 1..=6 {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("level", level),
|
||||
&level,
|
||||
|b, &level| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
(handler, doc_id, temp_dir)
|
||||
},
|
||||
|(mut handler, doc_id, _temp_dir)| {
|
||||
black_box(handler.add_heading(&doc_id, &format!("Heading Level {}", level), level).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_table_addition(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("add_table");
|
||||
|
||||
for size in [(2, 2), (5, 5), (10, 10), (20, 10)].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("size", format!("{}x{}", size.0, size.1)),
|
||||
size,
|
||||
|b, &(rows, cols)| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
let mut table_rows = Vec::new();
|
||||
for i in 0..rows {
|
||||
let mut row = Vec::new();
|
||||
for j in 0..cols {
|
||||
row.push(format!("Cell {}x{}", i, j));
|
||||
}
|
||||
table_rows.push(row);
|
||||
}
|
||||
|
||||
let table_data = TableData {
|
||||
rows: table_rows,
|
||||
headers: None,
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
|
||||
(handler, doc_id, temp_dir, table_data)
|
||||
},
|
||||
|(mut handler, doc_id, _temp_dir, table_data)| {
|
||||
black_box(handler.add_table(&doc_id, table_data).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_list_addition(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("add_list");
|
||||
|
||||
for item_count in [5, 20, 50, 100].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("items", item_count),
|
||||
item_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
let items: Vec<String> = (0..count)
|
||||
.map(|i| format!("List item number {}", i))
|
||||
.collect();
|
||||
|
||||
(handler, doc_id, temp_dir, items)
|
||||
},
|
||||
|(mut handler, doc_id, _temp_dir, items)| {
|
||||
black_box(handler.add_list(&doc_id, items, false).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_text_extraction(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("extract_text");
|
||||
|
||||
for paragraph_count in [10, 100, 500, 1000].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("paragraphs", paragraph_count),
|
||||
paragraph_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Create document with many paragraphs
|
||||
for i in 0..count {
|
||||
let text = format!("This is paragraph {} with substantial content to test text extraction performance. It includes various words and punctuation to make it realistic.", i);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
|
||||
(handler, doc_id, temp_dir)
|
||||
},
|
||||
|(handler, doc_id, _temp_dir)| {
|
||||
black_box(handler.extract_text(&doc_id).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_pdf_conversion(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("pdf_conversion");
|
||||
group.measurement_time(Duration::from_secs(30)); // Longer measurement for PDF conversion
|
||||
|
||||
for paragraph_count in [10, 50, 200].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("paragraphs", paragraph_count),
|
||||
paragraph_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Create substantial document content
|
||||
handler.add_heading(&doc_id, "Performance Test Document", 1).unwrap();
|
||||
|
||||
for i in 0..count {
|
||||
if i % 20 == 0 {
|
||||
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2).unwrap();
|
||||
}
|
||||
|
||||
let text = format!("This is paragraph {} designed to test PDF conversion performance. It contains enough text to make the conversion meaningful and test the system under realistic load conditions.", i);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id).unwrap();
|
||||
let converter = PureRustConverter::new();
|
||||
let output_path = temp_dir.path().join("benchmark.pdf");
|
||||
|
||||
(metadata, converter, output_path, temp_dir)
|
||||
},
|
||||
|(metadata, converter, output_path, _temp_dir)| {
|
||||
black_box(converter.convert_docx_to_pdf(&metadata.path, &output_path).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_image_conversion(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("image_conversion");
|
||||
group.measurement_time(Duration::from_secs(45)); // Even longer for image conversion
|
||||
|
||||
for paragraph_count in [5, 20, 50].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("paragraphs", paragraph_count),
|
||||
paragraph_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let (mut handler, temp_dir) = setup_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
handler.add_heading(&doc_id, "Image Conversion Test", 1).unwrap();
|
||||
|
||||
for i in 0..count {
|
||||
let text = format!("Paragraph {} for image conversion testing.", i);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id).unwrap();
|
||||
let converter = PureRustConverter::new();
|
||||
let output_dir = temp_dir.path().join("images");
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
|
||||
(metadata, converter, output_dir, temp_dir)
|
||||
},
|
||||
|(metadata, converter, output_dir, _temp_dir)| {
|
||||
black_box(converter.convert_docx_to_images(&metadata.path, &output_dir).unwrap())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_concurrent_operations(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("concurrent_operations");
|
||||
|
||||
for thread_count in [2, 4, 8].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("threads", thread_count),
|
||||
thread_count,
|
||||
|b, &threads| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
(temp_dir, threads)
|
||||
},
|
||||
|(temp_dir, thread_count)| {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
let temp_path = Arc::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let handles: Vec<_> = (0..thread_count).map(|i| {
|
||||
let temp_path = Arc::clone(&temp_path);
|
||||
thread::spawn(move || {
|
||||
let mut handler = DocxHandler::new_with_temp_dir(&temp_path).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
for j in 0..10 {
|
||||
let text = format!("Thread {} paragraph {}", i, j);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
|
||||
handler.extract_text(&doc_id).unwrap()
|
||||
})
|
||||
}).collect();
|
||||
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
black_box(())
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_memory_usage(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("memory_usage");
|
||||
|
||||
for doc_count in [5, 20, 50].iter() {
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("documents", doc_count),
|
||||
doc_count,
|
||||
|b, &count| {
|
||||
b.iter_batched(
|
||||
|| setup_handler(),
|
||||
|(mut handler, _temp_dir)| {
|
||||
let mut doc_ids = Vec::new();
|
||||
|
||||
// Create multiple documents
|
||||
for i in 0..count {
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Add content to each document
|
||||
handler.add_heading(&doc_id, &format!("Document {}", i), 1).unwrap();
|
||||
for j in 0..20 {
|
||||
let text = format!("Content paragraph {} in document {}", j, i);
|
||||
handler.add_paragraph(&doc_id, &text, None).unwrap();
|
||||
}
|
||||
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
|
||||
// Extract text from all documents
|
||||
for doc_id in &doc_ids {
|
||||
handler.extract_text(doc_id).unwrap();
|
||||
}
|
||||
|
||||
black_box(doc_ids)
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_complex_document_operations(c: &mut Criterion) {
|
||||
c.bench_function("complex_document", |b| {
|
||||
b.iter_batched(
|
||||
|| setup_handler(),
|
||||
|(mut handler, _temp_dir)| {
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Create a complex document with all features
|
||||
handler.add_heading(&doc_id, "Complex Document Test", 1).unwrap();
|
||||
handler.add_paragraph(&doc_id, "This is a comprehensive test document.", None).unwrap();
|
||||
|
||||
// Add styled paragraph
|
||||
let style = DocxStyle {
|
||||
font_size: Some(14),
|
||||
bold: Some(true),
|
||||
color: Some("#FF0000".to_string()),
|
||||
alignment: Some("center".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap();
|
||||
|
||||
// Add table
|
||||
let table_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()],
|
||||
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string(), "Row 1 Col 3".to_string()],
|
||||
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string(), "Row 2 Col 3".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Header 1".to_string(), "Header 2".to_string(), "Header 3".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, table_data).unwrap();
|
||||
|
||||
// Add list
|
||||
let items = vec![
|
||||
"First item".to_string(),
|
||||
"Second item".to_string(),
|
||||
"Third item".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, items, true).unwrap();
|
||||
|
||||
// Add page break and more content
|
||||
handler.add_page_break(&doc_id).unwrap();
|
||||
handler.add_heading(&doc_id, "Second Page", 1).unwrap();
|
||||
handler.add_paragraph(&doc_id, "Content on second page", None).unwrap();
|
||||
|
||||
// Set header and footer
|
||||
handler.set_header(&doc_id, "Document Header").unwrap();
|
||||
handler.set_footer(&doc_id, "Document Footer").unwrap();
|
||||
|
||||
// Extract all text
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
|
||||
black_box(text)
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_document_creation,
|
||||
bench_paragraph_addition,
|
||||
bench_styled_paragraph_addition,
|
||||
bench_heading_addition,
|
||||
bench_table_addition,
|
||||
bench_list_addition,
|
||||
bench_text_extraction,
|
||||
bench_pdf_conversion,
|
||||
bench_image_conversion,
|
||||
bench_concurrent_operations,
|
||||
bench_memory_usage,
|
||||
bench_complex_document_operations
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -1,37 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
// Create assets directory if it doesn't exist
|
||||
let fonts_dir = Path::new("assets/fonts");
|
||||
fs::create_dir_all(fonts_dir)?;
|
||||
|
||||
// Check if fonts exist, if not, create placeholder files
|
||||
// In production, you would download actual font files here
|
||||
let font_files = vec![
|
||||
"LiberationSans-Regular.ttf",
|
||||
"LiberationSans-Bold.ttf",
|
||||
"LiberationSans-Italic.ttf",
|
||||
"LiberationMono-Regular.ttf",
|
||||
"NotoSans-Regular.ttf",
|
||||
"NotoSans-Bold.ttf",
|
||||
];
|
||||
|
||||
for font_file in font_files {
|
||||
let font_path = fonts_dir.join(font_file);
|
||||
if !font_path.exists() {
|
||||
// For now, we'll create empty placeholder files
|
||||
// In production, download actual Liberation or Noto fonts (which are open source)
|
||||
println!("cargo:warning=Font file {} not found. Please download Liberation fonts from https://github.com/liberationfonts/liberation-fonts", font_file);
|
||||
|
||||
// Create a minimal placeholder TTF file (this won't work for actual rendering)
|
||||
// You should download the actual fonts
|
||||
fs::write(&font_path, &[0u8; 100])?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for DOCX MCP Server
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔨 Building DOCX MCP Server (Standalone Edition)..."
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check for Rust
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo -e "${RED}❌ Cargo not found. Please install Rust.${NC}"
|
||||
echo "Visit: https://www.rust-lang.org/tools/install"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if fonts are downloaded
|
||||
if [ ! -f "assets/fonts/LiberationSans-Regular.ttf" ]; then
|
||||
echo -e "${YELLOW}📥 Fonts not found. Downloading open-source fonts...${NC}"
|
||||
if [ -f "./download_fonts.sh" ]; then
|
||||
./download_fonts.sh
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Font files not found. The server will still work but with basic fonts.${NC}"
|
||||
echo -e "${YELLOW} Run ./download_fonts.sh to download professional fonts.${NC}"
|
||||
mkdir -p assets/fonts
|
||||
# Create placeholder files so build doesn't fail
|
||||
touch assets/fonts/LiberationSans-Regular.ttf
|
||||
touch assets/fonts/LiberationSans-Bold.ttf
|
||||
touch assets/fonts/LiberationSans-Italic.ttf
|
||||
touch assets/fonts/LiberationMono-Regular.ttf
|
||||
touch assets/fonts/NotoSans-Regular.ttf
|
||||
touch assets/fonts/NotoSans-Bold.ttf
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build mode selection
|
||||
BUILD_MODE=${1:-release}
|
||||
FEATURES=${2:-}
|
||||
|
||||
if [ "$BUILD_MODE" = "debug" ]; then
|
||||
echo -e "${YELLOW}📦 Building in debug mode...${NC}"
|
||||
if [ -n "$FEATURES" ]; then
|
||||
cargo build --features "$FEATURES"
|
||||
else
|
||||
cargo build
|
||||
fi
|
||||
BINARY_PATH="target/debug/docx-mcp"
|
||||
else
|
||||
echo -e "${YELLOW}📦 Building in release mode...${NC}"
|
||||
if [ -n "$FEATURES" ]; then
|
||||
cargo build --release --features "$FEATURES"
|
||||
else
|
||||
cargo build --release
|
||||
fi
|
||||
BINARY_PATH="target/release/docx-mcp"
|
||||
fi
|
||||
|
||||
# Check if build succeeded
|
||||
if [ -f "$BINARY_PATH" ]; then
|
||||
echo -e "${GREEN}✅ Build successful!${NC}"
|
||||
echo -e "Binary location: ${GREEN}$BINARY_PATH${NC}"
|
||||
|
||||
# Display standalone features
|
||||
echo -e "\n${BLUE}🎯 Standalone Features Enabled:${NC}"
|
||||
echo -e "${GREEN}✓${NC} Pure Rust DOCX parsing"
|
||||
echo -e "${GREEN}✓${NC} Built-in PDF generation"
|
||||
echo -e "${GREEN}✓${NC} Embedded fonts"
|
||||
echo -e "${GREEN}✓${NC} Native image processing"
|
||||
echo -e "${GREEN}✓${NC} Zero external dependencies required"
|
||||
|
||||
# Check for optional enhancements
|
||||
echo -e "\n${YELLOW}Optional enhancements (not required):${NC}"
|
||||
|
||||
if command -v libreoffice &> /dev/null; then
|
||||
echo -e "${GREEN}✓${NC} LibreOffice found (enhanced PDF conversion available)"
|
||||
else
|
||||
echo -e "${YELLOW}○${NC} LibreOffice not found (using built-in PDF converter)"
|
||||
echo " Optional: brew install libreoffice (macOS) or apt-get install libreoffice (Linux)"
|
||||
fi
|
||||
|
||||
if command -v pdftoppm &> /dev/null; then
|
||||
echo -e "${GREEN}✓${NC} pdftoppm found (PDF to image conversion available)"
|
||||
elif command -v convert &> /dev/null; then
|
||||
echo -e "${GREEN}✓${NC} ImageMagick found (PDF to image conversion available)"
|
||||
elif command -v gs &> /dev/null; then
|
||||
echo -e "${GREEN}✓${NC} Ghostscript found (PDF to image conversion available)"
|
||||
else
|
||||
echo -e "${YELLOW}○${NC} No PDF to image converter found"
|
||||
echo " Install one of: poppler-utils, imagemagick, or ghostscript"
|
||||
fi
|
||||
|
||||
# Create example output directories
|
||||
mkdir -p example/output example/images example/thumbnails
|
||||
|
||||
echo -e "\n${GREEN}🚀 Ready to run!${NC}"
|
||||
echo -e "Start the server with: ${GREEN}$BINARY_PATH${NC}"
|
||||
echo -e "Or with logging: ${GREEN}RUST_LOG=info $BINARY_PATH${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Build failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,107 +0,0 @@
|
||||
# cargo-deny configuration for dependency management and security
|
||||
|
||||
[graph]
|
||||
targets = [
|
||||
{ triple = "x86_64-unknown-linux-gnu" },
|
||||
{ triple = "x86_64-pc-windows-msvc" },
|
||||
{ triple = "x86_64-apple-darwin" },
|
||||
{ triple = "aarch64-apple-darwin" },
|
||||
]
|
||||
|
||||
[advisories]
|
||||
# The path where the advisory database is cloned/fetched into
|
||||
db-path = "~/.cargo/advisory-db"
|
||||
# The url(s) of the advisory databases to use
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
# The lint level for security vulnerabilities
|
||||
vulnerability = "deny"
|
||||
# The lint level for unmaintained crates
|
||||
unmaintained = "warn"
|
||||
# The lint level for crates that have been yanked from their source registry
|
||||
yanked = "warn"
|
||||
# The lint level for crates with security notices
|
||||
notice = "warn"
|
||||
# A list of advisory IDs to ignore. Note that ignored advisories will still
|
||||
# output a note when they are encountered.
|
||||
ignore = [
|
||||
#"RUSTSEC-0000-0000",
|
||||
]
|
||||
|
||||
[licenses]
|
||||
# The confidence threshold for detecting a license from a license text.
|
||||
confidence-threshold = 0.8
|
||||
# List of explicitly allowed licenses
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Unicode-DFS-2016",
|
||||
]
|
||||
# List of explicitly disallowed licenses
|
||||
deny = [
|
||||
"GPL-2.0",
|
||||
"GPL-3.0",
|
||||
"AGPL-3.0",
|
||||
]
|
||||
# Lint level for when multiple versions of the same license are detected
|
||||
copyleft = "warn"
|
||||
# Some crates don't have license files and we allow them specifically
|
||||
exceptions = [
|
||||
# Allow ring which has some complex licensing
|
||||
{ allow = ["MIT", "ISC", "OpenSSL"], name = "ring" },
|
||||
# webpki has Mozilla's license
|
||||
{ allow = ["ISC", "MIT", "MPL-2.0"], name = "webpki" },
|
||||
]
|
||||
|
||||
[[licenses.clarify]]
|
||||
name = "ring"
|
||||
# SPDX identifier
|
||||
expression = "MIT AND ISC AND OpenSSL"
|
||||
# License file paths
|
||||
license-files = [
|
||||
{ path = "LICENSE", hash = 0xbd0eed23 }
|
||||
]
|
||||
|
||||
[bans]
|
||||
# Lint level for when multiple versions of the same crate are detected
|
||||
multiple-versions = "warn"
|
||||
# Lint level for when a crate version requirement is `*`
|
||||
wildcards = "allow"
|
||||
# The graph highlighting used when creating dotgraphs for crates
|
||||
highlight = "all"
|
||||
# List of crates that are allowed. Use with care!
|
||||
allow = [
|
||||
#{ name = "ansi_term", version = "=0.11.0" },
|
||||
]
|
||||
# List of crates to deny
|
||||
deny = [
|
||||
# Insecure random number generation
|
||||
{ name = "openssl", version = "*", use-instead = "rustls" },
|
||||
# Unmaintained and insecure
|
||||
{ name = "chrono", version = "<0.4.20" },
|
||||
]
|
||||
# Certain crates/versions that will be skipped when doing duplicate detection.
|
||||
skip = [
|
||||
#{ name = "ansi_term", version = "=0.11.0" },
|
||||
]
|
||||
# Similarly to `skip` allows you to skip certain crates from being checked. Unlike
|
||||
# `skip`, a skipped crate is removed from the crate graph entirely.
|
||||
skip-tree = [
|
||||
#{ name = "ansi_term", version = "=0.11.0", depth = 20 },
|
||||
]
|
||||
|
||||
[sources]
|
||||
# Lint level for what to happen when a crate from a crate registry that is
|
||||
# not in the allow list is encountered
|
||||
unknown-registry = "warn"
|
||||
# Lint level for what to happen when a crate from a git repository that is not
|
||||
# in the allow list is encountered
|
||||
unknown-git = "warn"
|
||||
# List of URLs for allowed crate registries. Defaults to the crates.io index
|
||||
# if not specified. If it is specified but empty, no registries are allowed.
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# List of URLs for allowed Git repositories
|
||||
allow-git = []
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to download open-source fonts for embedded PDF generation
|
||||
# These fonts are used when creating PDFs without external dependencies
|
||||
|
||||
set -e
|
||||
|
||||
FONTS_DIR="assets/fonts"
|
||||
mkdir -p "$FONTS_DIR"
|
||||
|
||||
echo "📥 Downloading open-source fonts for standalone operation..."
|
||||
|
||||
# Liberation Fonts (Red Hat) - Open source replacements for Arial, Times New Roman, Courier
|
||||
LIBERATION_VERSION="2.1.5"
|
||||
LIBERATION_URL="https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-${LIBERATION_VERSION}.tar.gz"
|
||||
|
||||
# Download Liberation fonts
|
||||
echo "Downloading Liberation fonts..."
|
||||
curl -L "$LIBERATION_URL" -o /tmp/liberation-fonts.tar.gz
|
||||
tar -xzf /tmp/liberation-fonts.tar.gz -C /tmp/
|
||||
|
||||
# Copy the fonts we need
|
||||
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Regular.ttf" "$FONTS_DIR/"
|
||||
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Bold.ttf" "$FONTS_DIR/"
|
||||
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Italic.ttf" "$FONTS_DIR/"
|
||||
cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationMono-Regular.ttf" "$FONTS_DIR/"
|
||||
|
||||
# Noto Sans (Google) - Fallback font with wide Unicode coverage
|
||||
echo "Downloading Noto Sans fonts..."
|
||||
NOTO_BASE_URL="https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans"
|
||||
|
||||
curl -L "${NOTO_BASE_URL}/NotoSans-Regular.ttf" -o "$FONTS_DIR/NotoSans-Regular.ttf"
|
||||
curl -L "${NOTO_BASE_URL}/NotoSans-Bold.ttf" -o "$FONTS_DIR/NotoSans-Bold.ttf"
|
||||
|
||||
# Clean up
|
||||
rm -f /tmp/liberation-fonts.tar.gz
|
||||
rm -rf /tmp/liberation-fonts-ttf-${LIBERATION_VERSION}
|
||||
|
||||
echo "✅ Fonts downloaded successfully!"
|
||||
echo ""
|
||||
echo "Fonts installed in $FONTS_DIR:"
|
||||
ls -la "$FONTS_DIR"/*.ttf
|
||||
|
||||
echo ""
|
||||
echo "The application can now run completely standalone without external dependencies!"
|
||||
Submodule example/MCP-Doc deleted from 377d05f0a9
@@ -1,492 +0,0 @@
|
||||
# Advanced DOCX MCP Server Usage Examples
|
||||
|
||||
This document demonstrates the advanced capabilities of the DOCX MCP server with real-world examples.
|
||||
|
||||
## Professional Document Templates
|
||||
|
||||
### Creating a Business Report
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Create a professional quarterly report with our sales data"
|
||||
|
||||
// 1. Create from report template
|
||||
const doc = await mcp.call("create_from_template", {
|
||||
template: "Report"
|
||||
});
|
||||
|
||||
// 2. Set document properties
|
||||
await mcp.call("set_document_properties", {
|
||||
document_id: doc.document_id,
|
||||
properties: {
|
||||
title: "Q3 2024 Sales Report",
|
||||
subject: "Quarterly Business Review",
|
||||
author: "Sales Team",
|
||||
company: "TechCorp Inc",
|
||||
keywords: ["sales", "quarterly", "2024", "revenue"]
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Add custom sections with advanced formatting
|
||||
await mcp.call("add_section", {
|
||||
document_id: doc.document_id,
|
||||
section_config: {
|
||||
page_size: "Letter",
|
||||
landscape: false,
|
||||
margins: {
|
||||
top: 25.4,
|
||||
bottom: 25.4,
|
||||
left: 31.8,
|
||||
right: 25.4
|
||||
},
|
||||
columns: 1
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Add charts and data visualization
|
||||
await mcp.call("add_chart", {
|
||||
document_id: doc.document_id,
|
||||
chart_type: "Column",
|
||||
data: {
|
||||
title: "Quarterly Revenue Growth",
|
||||
categories: ["Q1", "Q2", "Q3"],
|
||||
series: [{
|
||||
name: "Revenue ($M)",
|
||||
values: [1.2, 1.5, 1.8]
|
||||
}]
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced Mail Merge Campaign
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Create personalized letters for our client list with custom fields"
|
||||
|
||||
// 1. Create template with merge fields
|
||||
const template = await mcp.call("create_from_template", {
|
||||
template: "BusinessLetter"
|
||||
});
|
||||
|
||||
await mcp.call("prepare_mail_merge_template", {
|
||||
document_id: template.document_id,
|
||||
fields: ["ClientName", "Company", "LastOrderDate", "AccountManager", "SpecialOffer"]
|
||||
});
|
||||
|
||||
// 2. Process each recipient
|
||||
const recipients = [
|
||||
{
|
||||
ClientName: "John Smith",
|
||||
Company: "ABC Corp",
|
||||
LastOrderDate: "2024-02-15",
|
||||
AccountManager: "Sarah Johnson",
|
||||
SpecialOffer: "20% off next order"
|
||||
}
|
||||
// ... more recipients
|
||||
];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
// Create personalized document
|
||||
const personalDoc = await mcp.call("merge_template", {
|
||||
template_id: template.document_id,
|
||||
data: recipient
|
||||
});
|
||||
|
||||
// Add watermark for draft review
|
||||
await mcp.call("add_watermark", {
|
||||
document_id: personalDoc.document_id,
|
||||
text: "CONFIDENTIAL",
|
||||
style: "Diagonal"
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Document Analysis & Review
|
||||
|
||||
### Comprehensive Document Analysis
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Analyze this contract for structure, formatting, and key terms"
|
||||
|
||||
const doc = await mcp.call("open_document", {
|
||||
path: "./contracts/service_agreement.docx"
|
||||
});
|
||||
|
||||
// 1. Get document structure
|
||||
const structure = await mcp.call("get_document_structure", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
// 2. Analyze formatting consistency
|
||||
const formatting = await mcp.call("analyze_formatting", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
// 3. Get detailed statistics
|
||||
const stats = await mcp.call("get_word_count", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
// 4. Search for key legal terms
|
||||
const terms = ["liability", "indemnification", "termination", "confidential"];
|
||||
for (const term of terms) {
|
||||
const results = await mcp.call("search_text", {
|
||||
document_id: doc.document_id,
|
||||
search_term: term,
|
||||
case_sensitive: false,
|
||||
whole_word: true
|
||||
});
|
||||
|
||||
console.log(`Found "${term}" ${results.total_matches} times`);
|
||||
}
|
||||
|
||||
// 5. Export analysis to Markdown
|
||||
await mcp.call("export_to_markdown", {
|
||||
document_id: doc.document_id,
|
||||
output_path: "./analysis/contract_analysis.md"
|
||||
});
|
||||
```
|
||||
|
||||
### Collaborative Review Process
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Set up this document for review with comments and track changes"
|
||||
|
||||
// 1. Enable track changes
|
||||
await mcp.call("enable_track_changes", {
|
||||
document_id: doc.document_id,
|
||||
author: "Legal Review Team"
|
||||
});
|
||||
|
||||
// 2. Add review comments
|
||||
await mcp.call("add_comment", {
|
||||
document_id: doc.document_id,
|
||||
text: "Payment terms in section 3.2",
|
||||
comment: "Consider reducing payment terms from 60 to 30 days",
|
||||
author: "Finance Team"
|
||||
});
|
||||
|
||||
// 3. Add footnotes for clarification
|
||||
await mcp.call("add_footnote", {
|
||||
document_id: doc.document_id,
|
||||
reference_text: "governing law",
|
||||
footnote_text: "This clause should specify the state jurisdiction for legal disputes"
|
||||
});
|
||||
|
||||
// 4. Create bookmarks for easy navigation
|
||||
await mcp.call("add_bookmark", {
|
||||
document_id: doc.document_id,
|
||||
bookmark_name: "payment_terms",
|
||||
text: "3.2 Payment Terms"
|
||||
});
|
||||
|
||||
// 5. Add cross-references
|
||||
await mcp.call("add_cross_reference", {
|
||||
document_id: doc.document_id,
|
||||
bookmark_name: "payment_terms",
|
||||
display_text: "See Payment Terms section"
|
||||
});
|
||||
```
|
||||
|
||||
## Security & Compliance Examples
|
||||
|
||||
### Readonly Document Review
|
||||
|
||||
```bash
|
||||
# Start server in readonly mode for document review only
|
||||
export DOCX_MCP_READONLY=true
|
||||
./target/release/docx-mcp
|
||||
```
|
||||
|
||||
```javascript
|
||||
// In readonly mode, these operations are available:
|
||||
const doc = await mcp.call("open_document", {
|
||||
path: "./confidential/annual_report.docx"
|
||||
});
|
||||
|
||||
// ✅ Allowed: Extract and analyze content
|
||||
const text = await mcp.call("extract_text", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
const structure = await mcp.call("get_document_structure", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
// ✅ Allowed: Export for analysis
|
||||
await mcp.call("export_to_markdown", {
|
||||
document_id: doc.document_id,
|
||||
output_path: "./analysis/report_content.md"
|
||||
});
|
||||
|
||||
// ❌ Blocked: Any modification attempts
|
||||
// These would return security errors:
|
||||
// - add_paragraph
|
||||
// - save_document
|
||||
// - find_and_replace
|
||||
```
|
||||
|
||||
### Sandboxed Environment
|
||||
|
||||
```bash
|
||||
# Run in sandbox mode - restricts file operations to temp directory
|
||||
export DOCX_MCP_SANDBOX=true
|
||||
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
|
||||
./target/release/docx-mcp
|
||||
```
|
||||
|
||||
```javascript
|
||||
// All file operations restricted to temporary directory
|
||||
// Perfect for untrusted document processing
|
||||
|
||||
const doc = await mcp.call("create_document", {});
|
||||
|
||||
// ✅ Allowed: Operations in temp directory
|
||||
await mcp.call("save_document", {
|
||||
document_id: doc.document_id,
|
||||
output_path: "/tmp/docx-mcp/safe_output.docx"
|
||||
});
|
||||
|
||||
// ❌ Blocked: Operations outside temp directory
|
||||
// This would return a security error:
|
||||
await mcp.call("save_document", {
|
||||
document_id: doc.document_id,
|
||||
output_path: "/home/user/documents/output.docx" // BLOCKED
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Automation Workflows
|
||||
|
||||
### Automated Report Generation Pipeline
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Create an automated monthly report generation system"
|
||||
|
||||
class ReportGenerator {
|
||||
async generateMonthlyReport(month, year, data) {
|
||||
// 1. Create from template
|
||||
const doc = await mcp.call("create_from_template", {
|
||||
template: "Report"
|
||||
});
|
||||
|
||||
// 2. Set up custom styles
|
||||
await mcp.call("add_custom_style", {
|
||||
document_id: doc.document_id,
|
||||
style: {
|
||||
id: "CompanyHeading",
|
||||
name: "Company Heading",
|
||||
font: "Arial",
|
||||
size: 18,
|
||||
bold: true,
|
||||
color: "#2E86C1",
|
||||
spacing: {
|
||||
before: 12,
|
||||
after: 6,
|
||||
line: 1.15
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Add dynamic content with bookmarks
|
||||
await mcp.call("add_bookmark", {
|
||||
document_id: doc.document_id,
|
||||
bookmark_name: "executive_summary",
|
||||
text: "Executive Summary"
|
||||
});
|
||||
|
||||
// 4. Insert data charts
|
||||
for (const metric of data.metrics) {
|
||||
await mcp.call("add_chart", {
|
||||
document_id: doc.document_id,
|
||||
chart_type: metric.type,
|
||||
data: {
|
||||
title: metric.title,
|
||||
categories: metric.categories,
|
||||
series: metric.series
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Add table of contents
|
||||
await mcp.call("add_table_of_contents", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
// 6. Apply watermark
|
||||
await mcp.call("add_watermark", {
|
||||
document_id: doc.document_id,
|
||||
text: "INTERNAL USE ONLY",
|
||||
style: "Horizontal"
|
||||
});
|
||||
|
||||
// 7. Generate multiple formats
|
||||
const filename = `monthly_report_${year}_${month}`;
|
||||
|
||||
// Save DOCX
|
||||
await mcp.call("save_document", {
|
||||
document_id: doc.document_id,
|
||||
output_path: `./reports/${filename}.docx`
|
||||
});
|
||||
|
||||
// Convert to PDF
|
||||
await mcp.call("convert_to_pdf", {
|
||||
document_id: doc.document_id,
|
||||
output_path: `./reports/${filename}.pdf`
|
||||
});
|
||||
|
||||
// Generate preview images
|
||||
await mcp.call("convert_to_images", {
|
||||
document_id: doc.document_id,
|
||||
output_dir: `./reports/previews/`,
|
||||
format: "png",
|
||||
dpi: 150
|
||||
});
|
||||
|
||||
return {
|
||||
docx: `./reports/${filename}.docx`,
|
||||
pdf: `./reports/${filename}.pdf`,
|
||||
preview: `./reports/previews/`
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Document Quality Assurance
|
||||
|
||||
```javascript
|
||||
// Ask your AI: "Create a document QA system that checks formatting and compliance"
|
||||
|
||||
class DocumentQA {
|
||||
async auditDocument(documentPath) {
|
||||
const doc = await mcp.call("open_document", {
|
||||
path: documentPath
|
||||
});
|
||||
|
||||
const audit = {
|
||||
document: documentPath,
|
||||
timestamp: new Date().toISOString(),
|
||||
issues: [],
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// 1. Check document structure
|
||||
const structure = await mcp.call("get_document_structure", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
if (structure.structure.filter(s => s.type === "heading").length < 2) {
|
||||
audit.issues.push("Document lacks proper heading structure");
|
||||
}
|
||||
|
||||
// 2. Analyze formatting consistency
|
||||
const formatting = await mcp.call("analyze_formatting", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
if (formatting.formatting_analysis.fonts_detected.length > 3) {
|
||||
audit.issues.push("Too many fonts used - limit to 2-3 for consistency");
|
||||
}
|
||||
|
||||
// 3. Check for required content
|
||||
const requiredTerms = ["confidential", "copyright", "contact"];
|
||||
for (const term of requiredTerms) {
|
||||
const search = await mcp.call("search_text", {
|
||||
document_id: doc.document_id,
|
||||
search_term: term,
|
||||
case_sensitive: false
|
||||
});
|
||||
|
||||
if (search.total_matches === 0) {
|
||||
audit.recommendations.push(`Consider adding ${term} information`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check document statistics
|
||||
const stats = await mcp.call("get_word_count", {
|
||||
document_id: doc.document_id
|
||||
});
|
||||
|
||||
if (stats.statistics.words < 500) {
|
||||
audit.issues.push("Document may be too short for professional standards");
|
||||
}
|
||||
|
||||
// 5. Generate audit report
|
||||
const auditDoc = await mcp.call("create_document", {});
|
||||
|
||||
await mcp.call("add_heading", {
|
||||
document_id: auditDoc.document_id,
|
||||
text: "Document Quality Audit Report",
|
||||
level: 1
|
||||
});
|
||||
|
||||
await mcp.call("add_paragraph", {
|
||||
document_id: auditDoc.document_id,
|
||||
text: `Audit completed for: ${documentPath}`
|
||||
});
|
||||
|
||||
// Add issues table
|
||||
const issuesData = audit.issues.map(issue => ["Issue", issue]);
|
||||
await mcp.call("add_table", {
|
||||
document_id: auditDoc.document_id,
|
||||
rows: [["Type", "Description"], ...issuesData]
|
||||
});
|
||||
|
||||
await mcp.call("save_document", {
|
||||
document_id: auditDoc.document_id,
|
||||
output_path: `./qa/audit_${Date.now()}.docx`
|
||||
});
|
||||
|
||||
return audit;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Configuration Examples
|
||||
|
||||
### Enterprise Security Setup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Enterprise security configuration script
|
||||
|
||||
# Readonly mode for document review workstations
|
||||
export DOCX_MCP_READONLY=true
|
||||
|
||||
# Whitelist only analysis and export commands
|
||||
export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,get_document_structure,analyze_formatting,get_word_count,search_text,export_to_markdown,export_to_html,list_documents,get_security_info"
|
||||
|
||||
# Sandbox mode for processing untrusted documents
|
||||
export DOCX_MCP_SANDBOX=true
|
||||
|
||||
# Resource limits
|
||||
export DOCX_MCP_MAX_SIZE=10485760 # 10MB max file size
|
||||
export DOCX_MCP_MAX_DOCS=5 # Max 5 open documents
|
||||
|
||||
# Disable external tools and network
|
||||
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
|
||||
export DOCX_MCP_NO_NETWORK=true
|
||||
|
||||
echo "🔒 Starting DOCX MCP Server in Enterprise Security Mode"
|
||||
./target/release/docx-mcp
|
||||
```
|
||||
|
||||
### Development Environment Setup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Development environment with full features
|
||||
|
||||
# Allow all operations but with reasonable limits
|
||||
export DOCX_MCP_MAX_SIZE=104857600 # 100MB max file size
|
||||
export DOCX_MCP_MAX_DOCS=25 # Max 25 open documents
|
||||
|
||||
# Enable all features
|
||||
unset DOCX_MCP_READONLY
|
||||
unset DOCX_MCP_SANDBOX
|
||||
unset DOCX_MCP_WHITELIST
|
||||
unset DOCX_MCP_BLACKLIST
|
||||
|
||||
echo "🚀 Starting DOCX MCP Server in Development Mode"
|
||||
./target/release/docx-mcp
|
||||
```
|
||||
|
||||
These examples demonstrate the full power and flexibility of the DOCX MCP server for professional document workflows, from simple document creation to complex enterprise automation systems.
|
||||
@@ -1,503 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Advanced automation example using the DOCX MCP Server.
|
||||
This demonstrates how to build document automation workflows.
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# This would normally be your MCP client library
|
||||
# For demonstration, we're showing the structure
|
||||
class MCPClient:
|
||||
"""Mock MCP Client for demonstration"""
|
||||
|
||||
async def call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Call an MCP tool"""
|
||||
# In reality, this would communicate with the MCP server
|
||||
print(f"Calling {tool_name} with {arguments}")
|
||||
return {"success": True, "result": {}}
|
||||
|
||||
# Initialize client
|
||||
mcp = MCPClient()
|
||||
|
||||
# === Example 1: Generate Monthly Reports ===
|
||||
async def generate_monthly_report(month: int, year: int, data: Dict[str, Any]):
|
||||
"""Generate a comprehensive monthly report"""
|
||||
|
||||
# Create new document
|
||||
doc = await mcp.call("create_document", {})
|
||||
doc_id = doc["result"]["document_id"]
|
||||
|
||||
# Add title page
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": f"{data['company_name']}",
|
||||
"level": 1
|
||||
})
|
||||
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": f"Monthly Report - {datetime(year, month, 1).strftime('%B %Y')}",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
await mcp.call("add_page_break", {"document_id": doc_id})
|
||||
|
||||
# Executive Summary
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": "Executive Summary",
|
||||
"level": 1
|
||||
})
|
||||
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": doc_id,
|
||||
"text": data["executive_summary"],
|
||||
"style": {
|
||||
"font_size": 12,
|
||||
"alignment": "justify"
|
||||
}
|
||||
})
|
||||
|
||||
# Key Metrics Table
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": "Key Performance Indicators",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
await mcp.call("add_table", {
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Metric", "Target", "Actual", "Variance"],
|
||||
["Revenue", f"${data['targets']['revenue']:,}", f"${data['actuals']['revenue']:,}",
|
||||
f"{((data['actuals']['revenue'] / data['targets']['revenue'] - 1) * 100):.1f}%"],
|
||||
["New Customers", str(data['targets']['customers']), str(data['actuals']['customers']),
|
||||
f"{data['actuals']['customers'] - data['targets']['customers']:+d}"],
|
||||
["Satisfaction Score", f"{data['targets']['satisfaction']}%", f"{data['actuals']['satisfaction']}%",
|
||||
f"{data['actuals']['satisfaction'] - data['targets']['satisfaction']:+.1f}%"]
|
||||
]
|
||||
})
|
||||
|
||||
# Department Reports
|
||||
for dept in data['departments']:
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": f"{dept['name']} Department",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": doc_id,
|
||||
"text": dept['summary']
|
||||
})
|
||||
|
||||
if dept.get('achievements'):
|
||||
await mcp.call("add_list", {
|
||||
"document_id": doc_id,
|
||||
"items": dept['achievements'],
|
||||
"ordered": False
|
||||
})
|
||||
|
||||
# Action Items
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": "Action Items for Next Month",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
await mcp.call("add_table", {
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Action", "Owner", "Due Date", "Priority"],
|
||||
*[[item['action'], item['owner'], item['due_date'], item['priority']]
|
||||
for item in data['action_items']]
|
||||
]
|
||||
})
|
||||
|
||||
# Add footer
|
||||
await mcp.call("set_footer", {
|
||||
"document_id": doc_id,
|
||||
"text": f"Confidential - {data['company_name']} - Page"
|
||||
})
|
||||
|
||||
# Save as DOCX
|
||||
filename = f"monthly_report_{year}_{month:02d}.docx"
|
||||
await mcp.call("save_document", {
|
||||
"document_id": doc_id,
|
||||
"output_path": f"./reports/{filename}"
|
||||
})
|
||||
|
||||
# Convert to PDF
|
||||
await mcp.call("convert_to_pdf", {
|
||||
"document_id": doc_id,
|
||||
"output_path": f"./reports/{filename.replace('.docx', '.pdf')}"
|
||||
})
|
||||
|
||||
# Generate thumbnail
|
||||
await mcp.call("convert_to_images", {
|
||||
"document_id": doc_id,
|
||||
"output_dir": "./reports/thumbnails/",
|
||||
"format": "png",
|
||||
"dpi": 72
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": doc_id})
|
||||
|
||||
return filename
|
||||
|
||||
# === Example 2: Mail Merge ===
|
||||
async def mail_merge(template_path: str, csv_path: str, output_dir: str):
|
||||
"""Perform mail merge with CSV data"""
|
||||
|
||||
# Read CSV data
|
||||
with open(csv_path, 'r') as f:
|
||||
reader = csv.DictReader(f)
|
||||
recipients = list(reader)
|
||||
|
||||
generated_files = []
|
||||
|
||||
for recipient in recipients:
|
||||
# Open template
|
||||
template = await mcp.call("open_document", {"path": template_path})
|
||||
doc_id = template["result"]["document_id"]
|
||||
|
||||
# Extract template text
|
||||
text_result = await mcp.call("extract_text", {"document_id": doc_id})
|
||||
text = text_result["result"]["text"]
|
||||
|
||||
# Replace placeholders
|
||||
for field, value in recipient.items():
|
||||
placeholder = f"{{{{{field}}}}}"
|
||||
if placeholder in text:
|
||||
await mcp.call("find_and_replace", {
|
||||
"document_id": doc_id,
|
||||
"find_text": placeholder,
|
||||
"replace_text": value
|
||||
})
|
||||
|
||||
# Save personalized document
|
||||
output_filename = f"{recipient.get('name', 'document').replace(' ', '_')}.docx"
|
||||
output_path = f"{output_dir}/{output_filename}"
|
||||
|
||||
await mcp.call("save_document", {
|
||||
"document_id": doc_id,
|
||||
"output_path": output_path
|
||||
})
|
||||
|
||||
# Convert to PDF
|
||||
pdf_path = output_path.replace('.docx', '.pdf')
|
||||
await mcp.call("convert_to_pdf", {
|
||||
"document_id": doc_id,
|
||||
"output_path": pdf_path
|
||||
})
|
||||
|
||||
generated_files.append({
|
||||
"recipient": recipient['name'],
|
||||
"docx": output_path,
|
||||
"pdf": pdf_path
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": doc_id})
|
||||
|
||||
# Create summary document
|
||||
summary = await mcp.call("create_document", {})
|
||||
summary_id = summary["result"]["document_id"]
|
||||
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": summary_id,
|
||||
"text": "Mail Merge Summary",
|
||||
"level": 1
|
||||
})
|
||||
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": summary_id,
|
||||
"text": f"Generated {len(generated_files)} documents on {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
})
|
||||
|
||||
# Add summary table
|
||||
rows = [["Recipient", "DOCX File", "PDF File"]]
|
||||
for file_info in generated_files:
|
||||
rows.append([
|
||||
file_info['recipient'],
|
||||
file_info['docx'],
|
||||
file_info['pdf']
|
||||
])
|
||||
|
||||
await mcp.call("add_table", {
|
||||
"document_id": summary_id,
|
||||
"rows": rows
|
||||
})
|
||||
|
||||
await mcp.call("save_document", {
|
||||
"document_id": summary_id,
|
||||
"output_path": f"{output_dir}/merge_summary.docx"
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": summary_id})
|
||||
|
||||
return generated_files
|
||||
|
||||
# === Example 3: Document Pipeline ===
|
||||
async def document_processing_pipeline(input_dir: str):
|
||||
"""Process multiple documents through a pipeline"""
|
||||
|
||||
input_path = Path(input_dir)
|
||||
docx_files = list(input_path.glob("*.docx"))
|
||||
|
||||
results = []
|
||||
|
||||
for docx_file in docx_files:
|
||||
print(f"Processing {docx_file.name}...")
|
||||
|
||||
# Open document
|
||||
doc = await mcp.call("open_document", {"path": str(docx_file)})
|
||||
doc_id = doc["result"]["document_id"]
|
||||
|
||||
# Add watermark (header)
|
||||
await mcp.call("set_header", {
|
||||
"document_id": doc_id,
|
||||
"text": "DRAFT - CONFIDENTIAL"
|
||||
})
|
||||
|
||||
# Add footer with date
|
||||
await mcp.call("set_footer", {
|
||||
"document_id": doc_id,
|
||||
"text": f"Processed on {datetime.now().strftime('%Y-%m-%d')}"
|
||||
})
|
||||
|
||||
# Extract text for indexing
|
||||
text_result = await mcp.call("extract_text", {"document_id": doc_id})
|
||||
text = text_result["result"]["text"]
|
||||
word_count = len(text.split())
|
||||
|
||||
# Save modified document
|
||||
output_docx = f"./processed/{docx_file.stem}_processed.docx"
|
||||
await mcp.call("save_document", {
|
||||
"document_id": doc_id,
|
||||
"output_path": output_docx
|
||||
})
|
||||
|
||||
# Convert to PDF
|
||||
output_pdf = output_docx.replace('.docx', '.pdf')
|
||||
await mcp.call("convert_to_pdf", {
|
||||
"document_id": doc_id,
|
||||
"output_path": output_pdf
|
||||
})
|
||||
|
||||
# Generate thumbnail
|
||||
await mcp.call("convert_to_images", {
|
||||
"document_id": doc_id,
|
||||
"output_dir": "./processed/thumbnails/",
|
||||
"format": "jpg",
|
||||
"dpi": 96
|
||||
})
|
||||
|
||||
results.append({
|
||||
"original": docx_file.name,
|
||||
"word_count": word_count,
|
||||
"docx": output_docx,
|
||||
"pdf": output_pdf
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": doc_id})
|
||||
|
||||
# Create index document
|
||||
index = await mcp.call("create_document", {})
|
||||
index_id = index["result"]["document_id"]
|
||||
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": index_id,
|
||||
"text": "Document Processing Report",
|
||||
"level": 1
|
||||
})
|
||||
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": index_id,
|
||||
"text": f"Processed {len(results)} documents"
|
||||
})
|
||||
|
||||
# Statistics table
|
||||
rows = [["Original File", "Word Count", "Output DOCX", "Output PDF"]]
|
||||
for result in results:
|
||||
rows.append([
|
||||
result['original'],
|
||||
str(result['word_count']),
|
||||
result['docx'],
|
||||
result['pdf']
|
||||
])
|
||||
|
||||
await mcp.call("add_table", {
|
||||
"document_id": index_id,
|
||||
"rows": rows
|
||||
})
|
||||
|
||||
await mcp.call("save_document", {
|
||||
"document_id": index_id,
|
||||
"output_path": "./processed/index.docx"
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": index_id})
|
||||
|
||||
return results
|
||||
|
||||
# === Example 4: Contract Generator ===
|
||||
async def generate_contract(contract_type: str, parties: Dict[str, Any], terms: Dict[str, Any]):
|
||||
"""Generate a legal contract based on type and terms"""
|
||||
|
||||
doc = await mcp.call("create_document", {})
|
||||
doc_id = doc["result"]["document_id"]
|
||||
|
||||
# Title
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": f"{contract_type.upper()} AGREEMENT",
|
||||
"level": 1
|
||||
})
|
||||
|
||||
# Date and parties
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": doc_id,
|
||||
"text": f"This Agreement is entered into as of {terms['date']} between:"
|
||||
})
|
||||
|
||||
await mcp.call("add_list", {
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
f"{parties['party1']['name']}, a {parties['party1']['type']} (\"Party 1\")",
|
||||
f"{parties['party2']['name']}, a {parties['party2']['type']} (\"Party 2\")"
|
||||
],
|
||||
"ordered": False
|
||||
})
|
||||
|
||||
# Terms sections
|
||||
section_num = 1
|
||||
for section_title, section_content in terms['sections'].items():
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": f"{section_num}. {section_title}",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
if isinstance(section_content, list):
|
||||
await mcp.call("add_list", {
|
||||
"document_id": doc_id,
|
||||
"items": section_content,
|
||||
"ordered": True
|
||||
})
|
||||
else:
|
||||
await mcp.call("add_paragraph", {
|
||||
"document_id": doc_id,
|
||||
"text": section_content
|
||||
})
|
||||
|
||||
section_num += 1
|
||||
|
||||
# Signature block
|
||||
await mcp.call("add_page_break", {"document_id": doc_id})
|
||||
await mcp.call("add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": "SIGNATURES",
|
||||
"level": 2
|
||||
})
|
||||
|
||||
signature_table = [
|
||||
["Party 1:", "", "Party 2:", ""],
|
||||
["", "", "", ""],
|
||||
["_" * 30, "", "_" * 30, ""],
|
||||
["Name:", parties['party1']['signatory'], "Name:", parties['party2']['signatory']],
|
||||
["Title:", parties['party1']['title'], "Title:", parties['party2']['title']],
|
||||
["Date:", "_" * 20, "Date:", "_" * 20]
|
||||
]
|
||||
|
||||
await mcp.call("add_table", {
|
||||
"document_id": doc_id,
|
||||
"rows": signature_table
|
||||
})
|
||||
|
||||
# Save and convert
|
||||
filename = f"{contract_type.lower().replace(' ', '_')}_{datetime.now().strftime('%Y%m%d')}"
|
||||
await mcp.call("save_document", {
|
||||
"document_id": doc_id,
|
||||
"output_path": f"./contracts/{filename}.docx"
|
||||
})
|
||||
|
||||
await mcp.call("convert_to_pdf", {
|
||||
"document_id": doc_id,
|
||||
"output_path": f"./contracts/{filename}.pdf"
|
||||
})
|
||||
|
||||
await mcp.call("close_document", {"document_id": doc_id})
|
||||
|
||||
return filename
|
||||
|
||||
# === Main execution ===
|
||||
async def main():
|
||||
"""Run example automations"""
|
||||
|
||||
print("Document Automation Examples")
|
||||
print("=" * 40)
|
||||
|
||||
# Example data for monthly report
|
||||
report_data = {
|
||||
"company_name": "TechCorp Industries",
|
||||
"executive_summary": "This month showed strong growth across all departments...",
|
||||
"targets": {"revenue": 1000000, "customers": 50, "satisfaction": 85},
|
||||
"actuals": {"revenue": 1150000, "customers": 62, "satisfaction": 88.5},
|
||||
"departments": [
|
||||
{
|
||||
"name": "Sales",
|
||||
"summary": "Sales exceeded targets by 15%",
|
||||
"achievements": ["Closed 3 enterprise deals", "Expanded into new market"]
|
||||
},
|
||||
{
|
||||
"name": "Engineering",
|
||||
"summary": "Delivered 2 major features on schedule",
|
||||
"achievements": ["Reduced bug count by 30%", "Improved performance by 25%"]
|
||||
}
|
||||
],
|
||||
"action_items": [
|
||||
{"action": "Hire 2 senior developers", "owner": "HR", "due_date": "2024-02-15", "priority": "High"},
|
||||
{"action": "Launch marketing campaign", "owner": "Marketing", "due_date": "2024-02-01", "priority": "Medium"}
|
||||
]
|
||||
}
|
||||
|
||||
# Generate monthly report
|
||||
print("\n1. Generating monthly report...")
|
||||
report_file = await generate_monthly_report(1, 2024, report_data)
|
||||
print(f" ✓ Generated: {report_file}")
|
||||
|
||||
# Contract generation
|
||||
print("\n2. Generating service agreement...")
|
||||
contract_file = await generate_contract(
|
||||
"Service Agreement",
|
||||
{
|
||||
"party1": {"name": "ABC Corp", "type": "corporation", "signatory": "John Smith", "title": "CEO"},
|
||||
"party2": {"name": "XYZ Ltd", "type": "limited company", "signatory": "Jane Doe", "title": "Director"}
|
||||
},
|
||||
{
|
||||
"date": "January 15, 2024",
|
||||
"sections": {
|
||||
"Scope of Services": "Party 2 agrees to provide consulting services...",
|
||||
"Payment Terms": ["Monthly fee of $10,000", "Payment due within 30 days", "Late fee of 1.5% per month"],
|
||||
"Term and Termination": "This agreement shall commence on the date first written above...",
|
||||
"Confidentiality": "Both parties agree to maintain strict confidentiality..."
|
||||
}
|
||||
}
|
||||
)
|
||||
print(f" ✓ Generated: {contract_file}")
|
||||
|
||||
print("\n✅ All automation examples completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create necessary directories
|
||||
for dir_path in ["./reports", "./reports/thumbnails", "./contracts", "./processed", "./processed/thumbnails"]:
|
||||
Path(dir_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Run examples
|
||||
asyncio.run(main())
|
||||
@@ -1,166 +0,0 @@
|
||||
# Claude Desktop Examples
|
||||
|
||||
These are real examples you can use with Claude Desktop once the DOCX MCP server is configured.
|
||||
|
||||
## Basic Document Creation
|
||||
|
||||
```
|
||||
You: Create a new Word document with a professional letterhead for "Acme Corp" and save it as letterhead.docx
|
||||
```
|
||||
|
||||
Claude will create a document with:
|
||||
- Company name as heading
|
||||
- Address and contact details
|
||||
- Professional formatting
|
||||
- Save to the specified file
|
||||
|
||||
## Invoice Generation
|
||||
|
||||
```
|
||||
You: Generate an invoice for client "TechStart Inc" with these items:
|
||||
- 10 hours consulting at $150/hour
|
||||
- 1 software license at $500
|
||||
- Add 10% tax
|
||||
Save as invoice_2024_001.docx and convert to PDF
|
||||
```
|
||||
|
||||
## Batch Processing
|
||||
|
||||
```
|
||||
You: I have 5 DOCX files in the ./reports folder. Please:
|
||||
1. Add page numbers to each
|
||||
2. Set the header to "Confidential - Internal Use Only"
|
||||
3. Convert all to PDF
|
||||
4. Create a summary document listing all reports
|
||||
```
|
||||
|
||||
## Data-Driven Documents
|
||||
|
||||
```
|
||||
You: Create a sales report from this data:
|
||||
Q1: $1.2M (15% growth)
|
||||
Q2: $1.5M (25% growth)
|
||||
Q3: $1.3M (8% growth)
|
||||
Q4: $1.8M (38% growth)
|
||||
|
||||
Include:
|
||||
- Executive summary
|
||||
- Quarterly breakdown table
|
||||
- Year-over-year comparison
|
||||
- Recommendations section
|
||||
Convert to PDF when done
|
||||
```
|
||||
|
||||
## Template Operations
|
||||
|
||||
```
|
||||
You: Open template.docx and replace these placeholders:
|
||||
- {{CLIENT_NAME}} with "John Smith"
|
||||
- {{DATE}} with today's date
|
||||
- {{PROJECT}} with "Website Redesign"
|
||||
- {{AMOUNT}} with "$5,000"
|
||||
Save as contract_john_smith.docx
|
||||
```
|
||||
|
||||
## Document Merging
|
||||
|
||||
```
|
||||
You: Merge these documents in order:
|
||||
1. cover_page.docx
|
||||
2. executive_summary.docx
|
||||
3. main_report.docx
|
||||
4. appendix.docx
|
||||
|
||||
Add page numbers and a table of contents, then save as final_report.docx
|
||||
```
|
||||
|
||||
## Text Extraction and Analysis
|
||||
|
||||
```
|
||||
You: Extract all text from the documents in ./legal folder and:
|
||||
1. Find all mentions of "liability"
|
||||
2. Create a summary document with each mention and its context
|
||||
3. Add a table showing which document contains which terms
|
||||
```
|
||||
|
||||
## Report Formatting
|
||||
|
||||
```
|
||||
You: Format this markdown content as a professional Word document:
|
||||
|
||||
# Project Status Report
|
||||
## Overview
|
||||
Project is on track...
|
||||
## Milestones
|
||||
- [x] Phase 1 complete
|
||||
- [ ] Phase 2 in progress
|
||||
## Budget
|
||||
Current spend: $45,000 of $100,000
|
||||
|
||||
Add proper styling, convert checkboxes to a status table, and export as PDF.
|
||||
```
|
||||
|
||||
## Document Comparison
|
||||
|
||||
```
|
||||
You: Open contract_v1.docx and contract_v2.docx, then:
|
||||
1. Extract text from both
|
||||
2. Create a new document highlighting the differences
|
||||
3. Add a summary table of all changes
|
||||
4. Save as contract_comparison.docx
|
||||
```
|
||||
|
||||
## Automated Documentation
|
||||
|
||||
```
|
||||
You: Create API documentation from this OpenAPI spec file (api.yaml):
|
||||
1. Generate a Word document with proper formatting
|
||||
2. Include endpoint descriptions in a table
|
||||
3. Add request/response examples
|
||||
4. Create a PDF version for distribution
|
||||
```
|
||||
|
||||
## Meeting Minutes Template
|
||||
|
||||
```
|
||||
You: Create a meeting minutes template with:
|
||||
- Company header
|
||||
- Date, time, attendees fields
|
||||
- Agenda items section
|
||||
- Action items table with owner and due date columns
|
||||
- Next meeting section
|
||||
Save as meeting_template.docx
|
||||
```
|
||||
|
||||
## Bulk Conversion
|
||||
|
||||
```
|
||||
You: Convert all Word documents in my Downloads folder to:
|
||||
1. PDF files in ./pdfs folder
|
||||
2. PNG images (first page only) in ./thumbnails folder
|
||||
3. Create an index.docx with links to all documents
|
||||
```
|
||||
|
||||
## Complex Formatting
|
||||
|
||||
```
|
||||
You: Create a technical specification document with:
|
||||
1. Title page with document version and date
|
||||
2. Table of contents (auto-generated)
|
||||
3. Multiple heading levels
|
||||
4. Code blocks with syntax highlighting effect
|
||||
5. Diagrams placeholder sections
|
||||
6. Numbered requirements list
|
||||
7. Glossary table at the end
|
||||
8. Footer with page numbers
|
||||
```
|
||||
|
||||
## Mail Merge Simulation
|
||||
|
||||
```
|
||||
You: I have a CSV with client data (clients.csv). For each client:
|
||||
1. Create a personalized letter using template.docx
|
||||
2. Replace all placeholders with client data
|
||||
3. Save as PDF with client name in filename
|
||||
4. Create a summary document listing all generated files
|
||||
```
|
||||
@@ -1,144 +0,0 @@
|
||||
{
|
||||
"claude_desktop": {
|
||||
"description": "Claude Desktop configuration",
|
||||
"file_location_macos": "~/Library/Application Support/Claude/claude_desktop_config.json",
|
||||
"file_location_windows": "%APPDATA%\\Claude\\claude_desktop_config.json",
|
||||
"config": {
|
||||
"mcpServers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cursor": {
|
||||
"description": "Cursor IDE configuration",
|
||||
"file_location": "~/.cursor/config.json or Settings UI",
|
||||
"config": {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"windsurf": {
|
||||
"description": "Windsurf (Codeium) configuration",
|
||||
"file_location": "~/.windsurf/config.json",
|
||||
"config": {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"continue_dev": {
|
||||
"description": "Continue.dev configuration",
|
||||
"file_location": "~/.continue/config.json",
|
||||
"config": {
|
||||
"models": [
|
||||
{
|
||||
"title": "Claude 3.5 Sonnet",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3-5-sonnet-20241022",
|
||||
"apiKey": "your-api-key",
|
||||
"mcp_servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"vscode_mcp": {
|
||||
"description": "VS Code with MCP Extension",
|
||||
"file_location": ".vscode/settings.json",
|
||||
"config": {
|
||||
"mcp.servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"zed": {
|
||||
"description": "Zed editor configuration",
|
||||
"file_location": "~/.config/zed/settings.json",
|
||||
"config": {
|
||||
"assistant": {
|
||||
"mcp_servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"neovim": {
|
||||
"description": "Neovim with MCP support",
|
||||
"file_location": "~/.config/nvim/mcp.json",
|
||||
"config": {
|
||||
"servers": {
|
||||
"docx": {
|
||||
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"multiple_servers_example": {
|
||||
"description": "Example with multiple MCP servers",
|
||||
"config": {
|
||||
"mcpServers": {
|
||||
"docx": {
|
||||
"command": "/path/to/docx-mcp/target/release/docx-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"RUST_LOG": "info"
|
||||
}
|
||||
},
|
||||
"filesystem": {
|
||||
"command": "/path/to/filesystem-mcp",
|
||||
"args": ["--root", "/home/user/documents"]
|
||||
},
|
||||
"github": {
|
||||
"command": "/path/to/github-mcp",
|
||||
"args": [],
|
||||
"env": {
|
||||
"GITHUB_TOKEN": "ghp_..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example client to test the DOCX MCP Server.
|
||||
This demonstrates how to interact with the server using JSON-RPC.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import asyncio
|
||||
import websockets
|
||||
|
||||
async def call_tool(websocket, tool_name, arguments):
|
||||
"""Call a tool on the MCP server"""
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
|
||||
await websocket.send(json.dumps(request))
|
||||
response = await websocket.recv()
|
||||
return json.loads(response)
|
||||
|
||||
async def main():
|
||||
# Connect to the MCP server (adjust the URI as needed)
|
||||
uri = "ws://localhost:3000" # Default MCP server port
|
||||
|
||||
async with websockets.connect(uri) as websocket:
|
||||
print("Connected to DOCX MCP Server")
|
||||
|
||||
# Example 1: Create a new document
|
||||
print("\n1. Creating new document...")
|
||||
result = await call_tool(websocket, "create_document", {})
|
||||
doc_id = result["result"]["document_id"]
|
||||
print(f" Document created with ID: {doc_id}")
|
||||
|
||||
# Example 2: Add a heading
|
||||
print("\n2. Adding heading...")
|
||||
result = await call_tool(websocket, "add_heading", {
|
||||
"document_id": doc_id,
|
||||
"text": "Sample Document",
|
||||
"level": 1
|
||||
})
|
||||
print(" Heading added")
|
||||
|
||||
# Example 3: Add a paragraph with styling
|
||||
print("\n3. Adding styled paragraph...")
|
||||
result = await call_tool(websocket, "add_paragraph", {
|
||||
"document_id": doc_id,
|
||||
"text": "This is a sample paragraph with custom styling.",
|
||||
"style": {
|
||||
"font_size": 14,
|
||||
"bold": True,
|
||||
"color": "#0066CC",
|
||||
"alignment": "center"
|
||||
}
|
||||
})
|
||||
print(" Styled paragraph added")
|
||||
|
||||
# Example 4: Add a table
|
||||
print("\n4. Adding table...")
|
||||
result = await call_tool(websocket, "add_table", {
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Product", "Price", "Quantity"],
|
||||
["Widget A", "$10.99", "100"],
|
||||
["Widget B", "$15.99", "75"],
|
||||
["Widget C", "$8.99", "150"]
|
||||
]
|
||||
})
|
||||
print(" Table added")
|
||||
|
||||
# Example 5: Add a numbered list
|
||||
print("\n5. Adding numbered list...")
|
||||
result = await call_tool(websocket, "add_list", {
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
"First item in the list",
|
||||
"Second item with more text",
|
||||
"Third and final item"
|
||||
],
|
||||
"ordered": True
|
||||
})
|
||||
print(" Numbered list added")
|
||||
|
||||
# Example 6: Set header and footer
|
||||
print("\n6. Setting header and footer...")
|
||||
result = await call_tool(websocket, "set_header", {
|
||||
"document_id": doc_id,
|
||||
"text": "Sample Document Header"
|
||||
})
|
||||
result = await call_tool(websocket, "set_footer", {
|
||||
"document_id": doc_id,
|
||||
"text": "Page 1 | Confidential"
|
||||
})
|
||||
print(" Header and footer set")
|
||||
|
||||
# Example 7: Save the document
|
||||
print("\n7. Saving document...")
|
||||
result = await call_tool(websocket, "save_document", {
|
||||
"document_id": doc_id,
|
||||
"output_path": "./sample_output.docx"
|
||||
})
|
||||
print(" Document saved to sample_output.docx")
|
||||
|
||||
# Example 8: Convert to PDF
|
||||
print("\n8. Converting to PDF...")
|
||||
result = await call_tool(websocket, "convert_to_pdf", {
|
||||
"document_id": doc_id,
|
||||
"output_path": "./sample_output.pdf"
|
||||
})
|
||||
if result["result"]["success"]:
|
||||
print(" Document converted to PDF")
|
||||
else:
|
||||
print(f" PDF conversion failed: {result['result'].get('error', 'Unknown error')}")
|
||||
|
||||
# Example 9: Convert to images
|
||||
print("\n9. Converting to images...")
|
||||
result = await call_tool(websocket, "convert_to_images", {
|
||||
"document_id": doc_id,
|
||||
"output_dir": "./images/",
|
||||
"format": "png",
|
||||
"dpi": 150
|
||||
})
|
||||
if result["result"]["success"]:
|
||||
print(f" Document converted to images: {result['result']['images']}")
|
||||
else:
|
||||
print(f" Image conversion failed: {result['result'].get('error', 'Unknown error')}")
|
||||
|
||||
# Example 10: Extract text
|
||||
print("\n10. Extracting text...")
|
||||
result = await call_tool(websocket, "extract_text", {
|
||||
"document_id": doc_id
|
||||
})
|
||||
text = result["result"]["text"]
|
||||
print(f" Extracted text (first 100 chars): {text[:100]}...")
|
||||
|
||||
# Example 11: Get metadata
|
||||
print("\n11. Getting metadata...")
|
||||
result = await call_tool(websocket, "get_metadata", {
|
||||
"document_id": doc_id
|
||||
})
|
||||
metadata = result["result"]["metadata"]
|
||||
print(f" Document metadata: {json.dumps(metadata, indent=2)}")
|
||||
|
||||
# Example 12: Close the document
|
||||
print("\n12. Closing document...")
|
||||
result = await call_tool(websocket, "close_document", {
|
||||
"document_id": doc_id
|
||||
})
|
||||
print(" Document closed")
|
||||
|
||||
print("\n✅ All tests completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,476 +0,0 @@
|
||||
# Justfile for docx-mcp project
|
||||
# Usage: just <command>
|
||||
# Install just: https://github.com/casey/just
|
||||
|
||||
# Default recipe
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Build the project
|
||||
build:
|
||||
cargo build --all-features
|
||||
|
||||
# Build for release
|
||||
build-release:
|
||||
cargo build --release --all-features
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
./scripts/run_tests.sh
|
||||
|
||||
# Run only unit tests
|
||||
test-unit:
|
||||
./scripts/run_tests.sh --unit-only
|
||||
|
||||
# Run only integration tests
|
||||
test-integration:
|
||||
./scripts/run_tests.sh --integration-only
|
||||
|
||||
# Run all tests including slow ones
|
||||
test-all:
|
||||
./scripts/run_tests.sh --all
|
||||
|
||||
# Run performance tests
|
||||
test-performance:
|
||||
./scripts/run_tests.sh --performance
|
||||
|
||||
# Generate coverage report
|
||||
coverage:
|
||||
./scripts/run_tests.sh --coverage
|
||||
|
||||
# Run benchmarks
|
||||
bench:
|
||||
cargo bench --all-features
|
||||
|
||||
# Check code formatting
|
||||
fmt-check:
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Run Clippy lints
|
||||
clippy:
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
# Fix Clippy issues automatically where possible
|
||||
clippy-fix:
|
||||
cargo clippy --all-targets --all-features --fix
|
||||
|
||||
# Run security audit
|
||||
audit:
|
||||
cargo audit
|
||||
|
||||
# Check dependencies for issues
|
||||
deny:
|
||||
cargo deny check
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Update dependencies
|
||||
update:
|
||||
cargo update
|
||||
|
||||
# Install development tools
|
||||
install-dev-tools:
|
||||
cargo install cargo-audit cargo-deny cargo-llvm-cov
|
||||
|
||||
# Run the application in development mode
|
||||
dev:
|
||||
RUST_LOG=debug cargo run --all-features
|
||||
|
||||
# Run the application in release mode
|
||||
run:
|
||||
cargo run --release --all-features
|
||||
|
||||
# Generate documentation
|
||||
docs:
|
||||
cargo doc --all-features --no-deps --open
|
||||
|
||||
# Check documentation
|
||||
docs-check:
|
||||
cargo doc --all-features --no-deps
|
||||
|
||||
# Package the project for distribution
|
||||
package:
|
||||
cargo package
|
||||
|
||||
# Publish to crates.io (dry run)
|
||||
publish-dry:
|
||||
cargo publish --dry-run
|
||||
|
||||
# Publish to crates.io
|
||||
publish:
|
||||
cargo publish
|
||||
|
||||
# Docker build
|
||||
docker-build:
|
||||
docker build -t docx-mcp:latest .
|
||||
|
||||
# Docker run
|
||||
docker-run:
|
||||
docker run -p 8080:8080 docx-mcp:latest
|
||||
|
||||
# Run pre-commit checks (formatting, linting, tests)
|
||||
pre-commit: fmt-check clippy test-unit
|
||||
|
||||
# Full CI pipeline simulation
|
||||
ci: pre-commit test audit
|
||||
|
||||
# Quick development cycle (format, build, test)
|
||||
dev-cycle: fmt build test-unit
|
||||
|
||||
# Setup development environment
|
||||
setup:
|
||||
rustup component add rustfmt clippy llvm-tools-preview
|
||||
just install-dev-tools
|
||||
|
||||
# Generate sample documents for testing
|
||||
generate-samples:
|
||||
cargo run --bin generate-test-docs --features=test-utils
|
||||
|
||||
# Run stress tests
|
||||
stress-test:
|
||||
STRESS_TEST=1 cargo test --release --test performance_tests -- --ignored --test-threads=1
|
||||
|
||||
# Profile the application
|
||||
profile:
|
||||
cargo build --release --all-features
|
||||
perf record -g target/release/docx-mcp
|
||||
perf report
|
||||
|
||||
# Memory usage analysis
|
||||
memory-check:
|
||||
cargo build --all-features
|
||||
valgrind --tool=memcheck --leak-check=full target/debug/docx-mcp
|
||||
|
||||
# Run with different Rust versions (requires rustup)
|
||||
test-msrv:
|
||||
rustup install 1.70.0
|
||||
rustup run 1.70.0 cargo test
|
||||
|
||||
# Check for outdated dependencies
|
||||
outdated:
|
||||
cargo install cargo-outdated
|
||||
cargo outdated
|
||||
|
||||
# Security scan
|
||||
security-scan: audit deny
|
||||
|
||||
# Performance profiling with flamegraph
|
||||
flamegraph:
|
||||
cargo install flamegraph
|
||||
cargo flamegraph --bin docx-mcp
|
||||
|
||||
# Generate changelog (requires git-cliff)
|
||||
changelog:
|
||||
git cliff --output CHANGELOG.md
|
||||
|
||||
# Release commands using the release script
|
||||
|
||||
# Create a patch release (0.1.0 -> 0.1.1)
|
||||
release-patch:
|
||||
./scripts/release.sh patch
|
||||
|
||||
# Create a minor release (0.1.0 -> 0.2.0)
|
||||
release-minor:
|
||||
./scripts/release.sh minor
|
||||
|
||||
# Create a major release (0.1.0 -> 1.0.0)
|
||||
release-major:
|
||||
./scripts/release.sh major
|
||||
|
||||
# Create a specific version release
|
||||
release-version version:
|
||||
./scripts/release.sh version {{version}}
|
||||
|
||||
# Dry run of patch release (see what would happen)
|
||||
release-patch-dry:
|
||||
./scripts/release.sh patch --dry-run
|
||||
|
||||
# Dry run of minor release
|
||||
release-minor-dry:
|
||||
./scripts/release.sh minor --dry-run
|
||||
|
||||
# Dry run of major release
|
||||
release-major-dry:
|
||||
./scripts/release.sh major --dry-run
|
||||
|
||||
# Dry run of specific version release
|
||||
release-version-dry version:
|
||||
./scripts/release.sh version {{version}} --dry-run
|
||||
|
||||
# Run all pre-release checks
|
||||
release-check:
|
||||
./scripts/release.sh check
|
||||
|
||||
# Generate changelog since last tag
|
||||
release-changelog:
|
||||
./scripts/release.sh changelog
|
||||
|
||||
# Create git tag for current version
|
||||
release-tag:
|
||||
./scripts/release.sh tag
|
||||
|
||||
# Prepare a release (legacy command - use release-* commands above)
|
||||
prepare-release version:
|
||||
@echo "⚠️ This command is deprecated. Use 'just release-version {{version}}' instead."
|
||||
@echo "The new release commands provide better automation and safety checks."
|
||||
@echo ""
|
||||
@echo "Available release commands:"
|
||||
@echo " just release-patch - Bump patch version"
|
||||
@echo " just release-minor - Bump minor version"
|
||||
@echo " just release-major - Bump major version"
|
||||
@echo " just release-version X.Y.Z - Set specific version"
|
||||
|
||||
# Show project statistics
|
||||
stats:
|
||||
@echo "=== Project Statistics ==="
|
||||
@echo "Lines of code:"
|
||||
@find src -name "*.rs" -type f -exec wc -l {} + | tail -n 1
|
||||
@echo ""
|
||||
@echo "Test coverage:"
|
||||
@just coverage --quiet | grep "Overall coverage" || echo "Run 'just coverage' first"
|
||||
@echo ""
|
||||
@echo "Dependencies:"
|
||||
@cargo tree --depth 1 | wc -l
|
||||
@echo ""
|
||||
@echo "Binary size (release):"
|
||||
@if [ -f "target/release/docx-mcp" ]; then ls -lh target/release/docx-mcp | awk '{print $5}'; else echo "Run 'just build-release' first"; fi
|
||||
|
||||
# Watch for changes and run tests
|
||||
watch:
|
||||
cargo install cargo-watch
|
||||
cargo watch -x "test --lib"
|
||||
|
||||
# Watch for changes and run specific test
|
||||
watch-test test_name:
|
||||
cargo watch -x "test {{test_name}}"
|
||||
|
||||
# Initialize git hooks
|
||||
init-hooks:
|
||||
#!/usr/bin/env bash
|
||||
cat > .git/hooks/pre-commit << 'EOF'
|
||||
#!/bin/bash
|
||||
just pre-commit
|
||||
EOF
|
||||
chmod +x .git/hooks/pre-commit
|
||||
echo "Pre-commit hook installed"
|
||||
|
||||
# Remove git hooks
|
||||
remove-hooks:
|
||||
rm -f .git/hooks/pre-commit
|
||||
echo "Pre-commit hook removed"
|
||||
|
||||
# Docker commands
|
||||
|
||||
# Build multi-platform Docker image
|
||||
docker-build-multiarch:
|
||||
docker buildx create --use --name multiarch || true
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t docx-mcp:latest .
|
||||
|
||||
# Build and tag Docker image for release
|
||||
docker-build-release version:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t docx-mcp:{{version}} \
|
||||
-t docx-mcp:latest \
|
||||
-t ghcr.io/hongkongkiwi/docx-mcp:{{version}} \
|
||||
-t ghcr.io/hongkongkiwi/docx-mcp:latest \
|
||||
.
|
||||
|
||||
# Push Docker images to registry
|
||||
docker-push version:
|
||||
docker push docx-mcp:{{version}}
|
||||
docker push docx-mcp:latest
|
||||
docker push ghcr.io/hongkongkiwi/docx-mcp:{{version}}
|
||||
docker push ghcr.io/hongkongkiwi/docx-mcp:latest
|
||||
|
||||
# Run Docker container with volume mount for testing
|
||||
docker-test:
|
||||
docker run --rm -it -v $(pwd)/test-docs:/test-docs docx-mcp:latest
|
||||
|
||||
# Development environment commands
|
||||
|
||||
# Full development setup from scratch
|
||||
dev-setup:
|
||||
# Install Rust if not present
|
||||
@if ! command -v rustup >/dev/null 2>&1; then \
|
||||
echo "Installing Rust..."; \
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y; \
|
||||
source ~/.cargo/env; \
|
||||
fi
|
||||
# Setup toolchain and tools
|
||||
just setup
|
||||
# Initialize git hooks
|
||||
just init-hooks
|
||||
# Build project
|
||||
just build
|
||||
echo "✅ Development environment ready!"
|
||||
|
||||
# Check system dependencies
|
||||
check-deps:
|
||||
@echo "=== System Dependencies Check ==="
|
||||
@echo "Checking required tools..."
|
||||
@command -v rustc >/dev/null && echo "✅ Rust compiler found" || echo "❌ Rust compiler not found"
|
||||
@command -v cargo >/dev/null && echo "✅ Cargo found" || echo "❌ Cargo not found"
|
||||
@command -v git >/dev/null && echo "✅ Git found" || echo "❌ Git not found"
|
||||
@command -v docker >/dev/null && echo "✅ Docker found" || echo "❌ Docker not found"
|
||||
@command -v just >/dev/null && echo "✅ Just found" || echo "❌ Just not found"
|
||||
@echo ""
|
||||
@echo "Optional tools:"
|
||||
@command -v libreoffice >/dev/null && echo "✅ LibreOffice found" || echo "⚠️ LibreOffice not found (optional)"
|
||||
@command -v pdftoppm >/dev/null && echo "✅ pdftoppm found" || echo "⚠️ pdftoppm not found (optional)"
|
||||
@command -v convert >/dev/null && echo "✅ ImageMagick convert found" || echo "⚠️ ImageMagick not found (optional)"
|
||||
|
||||
# Cross-compilation commands
|
||||
|
||||
# Build for all supported targets
|
||||
build-all-targets:
|
||||
# Install targets if not present
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
rustup target add x86_64-apple-darwin
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
# Build for each target
|
||||
cargo build --release --target x86_64-unknown-linux-gnu --all-features
|
||||
cargo build --release --target x86_64-unknown-linux-musl --all-features
|
||||
cargo build --release --target x86_64-apple-darwin --all-features
|
||||
@echo "✅ Built for all available targets"
|
||||
|
||||
# Build using cross for Linux targets
|
||||
build-cross-linux:
|
||||
cargo install cross --git https://github.com/cross-rs/cross
|
||||
cross build --release --target x86_64-unknown-linux-gnu --all-features
|
||||
cross build --release --target x86_64-unknown-linux-musl --all-features
|
||||
cross build --release --target aarch64-unknown-linux-gnu --all-features
|
||||
cross build --release --target aarch64-unknown-linux-musl --all-features
|
||||
|
||||
# Maintenance commands
|
||||
|
||||
# Update all dependencies to latest versions
|
||||
update-deps:
|
||||
cargo update
|
||||
cargo outdated --depth 1
|
||||
|
||||
# Check for security vulnerabilities and update
|
||||
security-update:
|
||||
cargo audit fix
|
||||
cargo update
|
||||
|
||||
# Clean everything (including registry cache)
|
||||
clean-all:
|
||||
cargo clean
|
||||
rm -rf ~/.cargo/registry/cache
|
||||
rm -rf ~/.cargo/git/db
|
||||
docker system prune -f
|
||||
|
||||
# Backup project (excluding target and build artifacts)
|
||||
backup:
|
||||
#!/usr/bin/env bash
|
||||
BACKUP_NAME="docx-mcp-backup-$(date +%Y%m%d-%H%M%S)"
|
||||
tar czf "${BACKUP_NAME}.tar.gz" \
|
||||
--exclude='target' \
|
||||
--exclude='.git' \
|
||||
--exclude='*.log' \
|
||||
--exclude='*.tmp' \
|
||||
.
|
||||
echo "✅ Backup created: ${BACKUP_NAME}.tar.gz"
|
||||
|
||||
# Development workflows
|
||||
|
||||
# Quick development loop (format, build, test unit, lint)
|
||||
dev-loop:
|
||||
just fmt
|
||||
just build
|
||||
just test-unit
|
||||
just clippy
|
||||
|
||||
# Full quality check (everything CI runs)
|
||||
quality-check:
|
||||
just fmt-check
|
||||
just clippy
|
||||
just test
|
||||
just docs-check
|
||||
just audit
|
||||
just deny
|
||||
|
||||
# Continuous development with file watching
|
||||
dev-watch:
|
||||
cargo install cargo-watch
|
||||
cargo watch -w src -w tests -x "build" -x "test --lib"
|
||||
|
||||
# Performance analysis
|
||||
perf-analysis:
|
||||
# Build optimized release
|
||||
cargo build --release --all-features
|
||||
# Run criterion benchmarks
|
||||
cargo bench --all-features
|
||||
# Generate flamegraph if available
|
||||
@if command -v flamegraph >/dev/null 2>&1; then \
|
||||
echo "Generating flamegraph..."; \
|
||||
cargo flamegraph --bin docx-mcp -- --help; \
|
||||
fi
|
||||
|
||||
# MCP-specific commands
|
||||
|
||||
# Test MCP server functionality
|
||||
test-mcp:
|
||||
@echo "Testing MCP server..."
|
||||
# Build the server
|
||||
cargo build --release --all-features
|
||||
# Run basic functionality test
|
||||
python3 example/test_client.py || echo "❌ MCP test failed"
|
||||
|
||||
# Generate MCP documentation
|
||||
mcp-docs:
|
||||
@echo "Generating MCP server documentation..."
|
||||
cargo run --bin docx-mcp -- --help > docs/CLI_REFERENCE.md
|
||||
@echo "✅ CLI reference updated"
|
||||
|
||||
# Example commands
|
||||
|
||||
# Run all examples
|
||||
run-examples:
|
||||
@echo "Running all examples..."
|
||||
@if [ -f example/test_client.py ]; then python3 example/test_client.py; fi
|
||||
@if [ -f example/automation_example.py ]; then python3 example/automation_example.py; fi
|
||||
|
||||
# Generate test documents
|
||||
gen-test-docs:
|
||||
@echo "Generating test documents..."
|
||||
mkdir -p test-docs
|
||||
# You could add commands here to generate various test DOCX files
|
||||
|
||||
# Utility commands
|
||||
|
||||
# Show detailed project info
|
||||
info:
|
||||
@echo "=== Project Information ==="
|
||||
@echo "Name: docx-mcp"
|
||||
@echo "Version: $(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
|
||||
@echo "Rust version: $(rustc --version)"
|
||||
@echo "Cargo version: $(cargo --version)"
|
||||
@echo ""
|
||||
just stats
|
||||
|
||||
# List all available commands with descriptions
|
||||
help:
|
||||
@echo "=== Available Commands ==="
|
||||
@just --list
|
||||
@echo ""
|
||||
@echo "=== Release Commands ==="
|
||||
@echo " release-patch - Create patch release (0.1.0 -> 0.1.1)"
|
||||
@echo " release-minor - Create minor release (0.1.0 -> 0.2.0)"
|
||||
@echo " release-major - Create major release (0.1.0 -> 1.0.0)"
|
||||
@echo " release-version X - Create specific version release"
|
||||
@echo " release-*-dry - Dry run versions of above commands"
|
||||
@echo ""
|
||||
@echo "=== Development Workflows ==="
|
||||
@echo " dev-loop - Quick development cycle"
|
||||
@echo " quality-check - Full quality assessment"
|
||||
@echo " dev-setup - Complete development environment setup"
|
||||
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "py-docx-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Python MCP server for DOCX document manipulation"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"python-docx>=1.1.0",
|
||||
"Pillow>=10.0.0",
|
||||
"markdown>=3.5",
|
||||
"html5lib>=1.1",
|
||||
"regex>=2024.0.0",
|
||||
"aiofiles>=24.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
py-docx-mcp = "server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["*"]
|
||||
@@ -1,355 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Release script for docx-mcp
|
||||
# This script helps with version management and release preparation
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Helper functions
|
||||
info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
# Check if we're in a git repository
|
||||
check_git_repo() {
|
||||
if ! git rev-parse --git-dir > /dev/null 2>&1; then
|
||||
error "Not in a git repository"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if working directory is clean
|
||||
check_clean_working_dir() {
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
error "Working directory is not clean. Please commit or stash your changes."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Get current version from Cargo.toml
|
||||
get_current_version() {
|
||||
grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'
|
||||
}
|
||||
|
||||
# Update version in Cargo.toml
|
||||
update_version() {
|
||||
local new_version=$1
|
||||
info "Updating version to $new_version"
|
||||
|
||||
# Update Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$new_version\"/" Cargo.toml
|
||||
rm Cargo.toml.bak
|
||||
|
||||
# Update Cargo.lock
|
||||
cargo update -p docx-mcp
|
||||
|
||||
success "Version updated to $new_version"
|
||||
}
|
||||
|
||||
# Generate changelog since last tag
|
||||
generate_changelog() {
|
||||
local last_tag=$(git tag --sort=-version:refname | head -1)
|
||||
local new_version=$1
|
||||
|
||||
info "Generating changelog since $last_tag"
|
||||
|
||||
if [ -n "$last_tag" ]; then
|
||||
git log --pretty=format:"- %s (%h)" --no-merges ${last_tag}..HEAD > CHANGELOG.tmp
|
||||
else
|
||||
git log --pretty=format:"- %s (%h)" --no-merges > CHANGELOG.tmp
|
||||
fi
|
||||
|
||||
echo "## Release $new_version ($(date +%Y-%m-%d))"
|
||||
echo ""
|
||||
cat CHANGELOG.tmp
|
||||
echo ""
|
||||
rm CHANGELOG.tmp
|
||||
}
|
||||
|
||||
# Run pre-release checks
|
||||
run_checks() {
|
||||
info "Running pre-release checks..."
|
||||
|
||||
# Format check
|
||||
info "Checking code formatting..."
|
||||
cargo fmt --all -- --check
|
||||
success "Code formatting is correct"
|
||||
|
||||
# Clippy check
|
||||
info "Running Clippy..."
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
success "Clippy checks passed"
|
||||
|
||||
# Tests
|
||||
info "Running tests..."
|
||||
cargo test --all-features
|
||||
success "All tests passed"
|
||||
|
||||
# Build check
|
||||
info "Testing release build..."
|
||||
cargo build --release --all-features
|
||||
success "Release build successful"
|
||||
|
||||
# Package check
|
||||
info "Testing package..."
|
||||
cargo package --dry-run
|
||||
success "Package validation passed"
|
||||
}
|
||||
|
||||
# Create and push git tag
|
||||
create_tag() {
|
||||
local version=$1
|
||||
local tag="v$version"
|
||||
|
||||
info "Creating git tag $tag"
|
||||
|
||||
# Create annotated tag
|
||||
git tag -a "$tag" -m "Release $tag"
|
||||
|
||||
success "Created tag $tag"
|
||||
|
||||
# Ask if user wants to push
|
||||
echo -n "Push tag to origin? [y/N]: "
|
||||
read -r response
|
||||
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||
git push origin "$tag"
|
||||
success "Tag pushed to origin"
|
||||
else
|
||||
warning "Tag not pushed. Remember to push it manually: git push origin $tag"
|
||||
fi
|
||||
}
|
||||
|
||||
# Show usage information
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage: $0 [COMMAND] [OPTIONS]
|
||||
|
||||
Commands:
|
||||
patch Bump patch version (0.1.0 -> 0.1.1)
|
||||
minor Bump minor version (0.1.0 -> 0.2.0)
|
||||
major Bump major version (0.1.0 -> 1.0.0)
|
||||
version X.Y.Z Set specific version
|
||||
check Run pre-release checks only
|
||||
changelog Generate changelog since last tag
|
||||
tag Create git tag for current version
|
||||
|
||||
Options:
|
||||
--dry-run Show what would be done without making changes
|
||||
--no-checks Skip pre-release checks (not recommended)
|
||||
--no-tag Don't create git tag
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
$0 patch # Bump to next patch version
|
||||
$0 version 1.0.0 # Set version to 1.0.0
|
||||
$0 check # Run all pre-release checks
|
||||
$0 patch --dry-run # Show what patch release would do
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse version bump type
|
||||
bump_version() {
|
||||
local current_version=$1
|
||||
local bump_type=$2
|
||||
|
||||
# Split version into components
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$current_version"
|
||||
local major=${VERSION_PARTS[0]}
|
||||
local minor=${VERSION_PARTS[1]}
|
||||
local patch=${VERSION_PARTS[2]}
|
||||
|
||||
case $bump_type in
|
||||
"patch")
|
||||
patch=$((patch + 1))
|
||||
;;
|
||||
"minor")
|
||||
minor=$((minor + 1))
|
||||
patch=0
|
||||
;;
|
||||
"major")
|
||||
major=$((major + 1))
|
||||
minor=0
|
||||
patch=0
|
||||
;;
|
||||
*)
|
||||
error "Invalid bump type: $bump_type"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "${major}.${minor}.${patch}"
|
||||
}
|
||||
|
||||
# Validate version format
|
||||
validate_version() {
|
||||
local version=$1
|
||||
if ! [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
error "Invalid version format: $version"
|
||||
error "Expected format: X.Y.Z or X.Y.Z-suffix"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main script logic
|
||||
main() {
|
||||
local command=$1
|
||||
local dry_run=false
|
||||
local no_checks=false
|
||||
local no_tag=false
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--no-checks)
|
||||
no_checks=true
|
||||
shift
|
||||
;;
|
||||
--no-tag)
|
||||
no_tag=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -z "$command" ]; then
|
||||
command=$1
|
||||
elif [ -z "$version_arg" ] && [ "$command" = "version" ]; then
|
||||
version_arg=$1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check if command provided
|
||||
if [ -z "$command" ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Basic checks
|
||||
check_git_repo
|
||||
|
||||
if [ "$dry_run" = false ]; then
|
||||
check_clean_working_dir
|
||||
fi
|
||||
|
||||
current_version=$(get_current_version)
|
||||
info "Current version: $current_version"
|
||||
|
||||
case $command in
|
||||
"patch"|"minor"|"major")
|
||||
new_version=$(bump_version "$current_version" "$command")
|
||||
;;
|
||||
"version")
|
||||
if [ -z "$version_arg" ]; then
|
||||
error "Version argument required for 'version' command"
|
||||
exit 1
|
||||
fi
|
||||
new_version=$version_arg
|
||||
validate_version "$new_version"
|
||||
;;
|
||||
"check")
|
||||
run_checks
|
||||
success "All pre-release checks passed!"
|
||||
exit 0
|
||||
;;
|
||||
"changelog")
|
||||
generate_changelog "$current_version"
|
||||
exit 0
|
||||
;;
|
||||
"tag")
|
||||
if [ "$dry_run" = true ]; then
|
||||
info "Would create tag v$current_version"
|
||||
else
|
||||
create_tag "$current_version"
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown command: $command"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
info "New version will be: $new_version"
|
||||
|
||||
if [ "$dry_run" = true ]; then
|
||||
warning "DRY RUN MODE - No changes will be made"
|
||||
info "Would update version from $current_version to $new_version"
|
||||
if [ "$no_checks" = false ]; then
|
||||
info "Would run pre-release checks"
|
||||
fi
|
||||
if [ "$no_tag" = false ]; then
|
||||
info "Would create git tag v$new_version"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Confirm with user
|
||||
echo -n "Proceed with release $new_version? [y/N]: "
|
||||
read -r response
|
||||
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||
warning "Release cancelled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run pre-release checks
|
||||
if [ "$no_checks" = false ]; then
|
||||
run_checks
|
||||
fi
|
||||
|
||||
# Update version
|
||||
update_version "$new_version"
|
||||
|
||||
# Commit version bump
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "Release $new_version"
|
||||
success "Version bump committed"
|
||||
|
||||
# Create tag
|
||||
if [ "$no_tag" = false ]; then
|
||||
create_tag "$new_version"
|
||||
fi
|
||||
|
||||
# Generate changelog for reference
|
||||
info "Changelog for release:"
|
||||
generate_changelog "$new_version"
|
||||
|
||||
success "Release $new_version completed!"
|
||||
info "Next steps:"
|
||||
info "1. Push commits: git push origin main"
|
||||
if [ "$no_tag" = false ]; then
|
||||
info "2. Push tag: git push origin v$new_version (if not done already)"
|
||||
fi
|
||||
info "3. GitHub Actions will automatically create the release"
|
||||
}
|
||||
|
||||
# Run main function with all arguments
|
||||
main "$@"
|
||||
@@ -1,312 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Comprehensive test runner script for docx-mcp
|
||||
# Usage: ./scripts/run_tests.sh [OPTIONS]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Default options
|
||||
RUN_UNIT_TESTS=true
|
||||
RUN_INTEGRATION_TESTS=true
|
||||
RUN_PERFORMANCE_TESTS=false
|
||||
RUN_BENCHMARKS=false
|
||||
RUN_SECURITY_AUDIT=true
|
||||
RUN_COVERAGE=false
|
||||
VERBOSE=false
|
||||
QUIET=false
|
||||
CLEAN_FIRST=false
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Function to show help
|
||||
show_help() {
|
||||
cat << EOF
|
||||
Usage: $0 [OPTIONS]
|
||||
|
||||
Test runner script for docx-mcp project
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Show this help message
|
||||
-u, --unit-only Run only unit tests
|
||||
-i, --integration-only Run only integration tests
|
||||
-p, --performance Include performance tests (slow)
|
||||
-b, --benchmarks Run benchmarks (slow)
|
||||
-s, --skip-security Skip security audit
|
||||
-c, --coverage Generate coverage report
|
||||
-v, --verbose Verbose output
|
||||
-q, --quiet Quiet output (errors only)
|
||||
--clean Clean build artifacts first
|
||||
--all Run all tests including slow ones
|
||||
|
||||
Examples:
|
||||
$0 # Run standard test suite
|
||||
$0 -u # Run only unit tests
|
||||
$0 --all # Run all tests including performance
|
||||
$0 -c --verbose # Generate coverage with verbose output
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
-u|--unit-only)
|
||||
RUN_UNIT_TESTS=true
|
||||
RUN_INTEGRATION_TESTS=false
|
||||
shift
|
||||
;;
|
||||
-i|--integration-only)
|
||||
RUN_UNIT_TESTS=false
|
||||
RUN_INTEGRATION_TESTS=true
|
||||
shift
|
||||
;;
|
||||
-p|--performance)
|
||||
RUN_PERFORMANCE_TESTS=true
|
||||
shift
|
||||
;;
|
||||
-b|--benchmarks)
|
||||
RUN_BENCHMARKS=true
|
||||
shift
|
||||
;;
|
||||
-s|--skip-security)
|
||||
RUN_SECURITY_AUDIT=false
|
||||
shift
|
||||
;;
|
||||
-c|--coverage)
|
||||
RUN_COVERAGE=true
|
||||
shift
|
||||
;;
|
||||
-v|--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-q|--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
--clean)
|
||||
CLEAN_FIRST=true
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
RUN_UNIT_TESTS=true
|
||||
RUN_INTEGRATION_TESTS=true
|
||||
RUN_PERFORMANCE_TESTS=true
|
||||
RUN_BENCHMARKS=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Set up output redirection based on quiet flag
|
||||
if [ "$QUIET" = true ]; then
|
||||
CARGO_OUTPUT="--quiet"
|
||||
else
|
||||
CARGO_OUTPUT=""
|
||||
fi
|
||||
|
||||
if [ "$VERBOSE" = true ]; then
|
||||
CARGO_OUTPUT="$CARGO_OUTPUT --verbose"
|
||||
fi
|
||||
|
||||
print_status "Starting docx-mcp test suite"
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "Cargo.toml" ]; then
|
||||
print_error "Cargo.toml not found. Please run this script from the project root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Rust is installed
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
print_error "Cargo not found. Please install Rust first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean build artifacts if requested
|
||||
if [ "$CLEAN_FIRST" = true ]; then
|
||||
print_status "Cleaning build artifacts..."
|
||||
cargo clean $CARGO_OUTPUT
|
||||
fi
|
||||
|
||||
# Check formatting
|
||||
print_status "Checking code formatting..."
|
||||
if ! cargo fmt --all -- --check; then
|
||||
print_error "Code formatting issues found. Run 'cargo fmt' to fix."
|
||||
exit 1
|
||||
fi
|
||||
print_success "Code formatting OK"
|
||||
|
||||
# Run Clippy lints
|
||||
print_status "Running Clippy lints..."
|
||||
if ! cargo clippy --all-targets --all-features $CARGO_OUTPUT -- -D warnings; then
|
||||
print_error "Clippy lints failed"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Clippy lints passed"
|
||||
|
||||
# Build the project
|
||||
print_status "Building project..."
|
||||
if ! cargo build --all-features $CARGO_OUTPUT; then
|
||||
print_error "Build failed"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Build completed"
|
||||
|
||||
# Initialize test results tracking
|
||||
TESTS_PASSED=0
|
||||
TESTS_FAILED=0
|
||||
FAILED_TESTS=()
|
||||
|
||||
# Function to run a test and track results
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_command="$2"
|
||||
|
||||
print_status "Running $test_name..."
|
||||
|
||||
if eval $test_command; then
|
||||
print_success "$test_name passed"
|
||||
((TESTS_PASSED++))
|
||||
else
|
||||
print_error "$test_name failed"
|
||||
((TESTS_FAILED++))
|
||||
FAILED_TESTS+=("$test_name")
|
||||
fi
|
||||
}
|
||||
|
||||
# Run unit tests
|
||||
if [ "$RUN_UNIT_TESTS" = true ]; then
|
||||
run_test "unit tests" "cargo test --lib $CARGO_OUTPUT"
|
||||
run_test "doc tests" "cargo test --doc $CARGO_OUTPUT"
|
||||
fi
|
||||
|
||||
# Run integration tests
|
||||
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
|
||||
run_test "DOCX handler tests" "cargo test --test docx_handler_tests $CARGO_OUTPUT"
|
||||
run_test "MCP integration tests" "cargo test --test mcp_integration_tests $CARGO_OUTPUT"
|
||||
run_test "security tests" "cargo test --test security_tests $CARGO_OUTPUT"
|
||||
run_test "converter tests" "cargo test --test converter_tests $CARGO_OUTPUT"
|
||||
run_test "end-to-end workflow tests" "cargo test --test e2e_workflow_tests $CARGO_OUTPUT"
|
||||
fi
|
||||
|
||||
# Run performance tests (if requested)
|
||||
if [ "$RUN_PERFORMANCE_TESTS" = true ]; then
|
||||
print_warning "Running performance tests (this may take a while)..."
|
||||
run_test "performance tests" "cargo test --test performance_tests $CARGO_OUTPUT --release"
|
||||
fi
|
||||
|
||||
# Run benchmarks (if requested)
|
||||
if [ "$RUN_BENCHMARKS" = true ]; then
|
||||
print_warning "Running benchmarks (this may take a while)..."
|
||||
run_test "benchmarks" "cargo bench --all-features $CARGO_OUTPUT"
|
||||
fi
|
||||
|
||||
# Run security audit
|
||||
if [ "$RUN_SECURITY_AUDIT" = true ]; then
|
||||
print_status "Running security audit..."
|
||||
|
||||
# Install cargo-audit if not present
|
||||
if ! command -v cargo-audit &> /dev/null; then
|
||||
print_status "Installing cargo-audit..."
|
||||
cargo install cargo-audit
|
||||
fi
|
||||
|
||||
run_test "security audit" "cargo audit"
|
||||
|
||||
# Check for denied dependencies if cargo-deny is available
|
||||
if command -v cargo-deny &> /dev/null; then
|
||||
run_test "dependency check" "cargo deny check"
|
||||
else
|
||||
print_warning "cargo-deny not found, skipping dependency checks"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Generate coverage report (if requested)
|
||||
if [ "$RUN_COVERAGE" = true ]; then
|
||||
print_status "Generating coverage report..."
|
||||
|
||||
# Check if cargo-llvm-cov is installed
|
||||
if ! command -v cargo-llvm-cov &> /dev/null; then
|
||||
print_status "Installing cargo-llvm-cov..."
|
||||
cargo install cargo-llvm-cov
|
||||
fi
|
||||
|
||||
if cargo llvm-cov --all-features --workspace --html; then
|
||||
print_success "Coverage report generated in target/llvm-cov/html/"
|
||||
|
||||
# Also generate lcov format for CI
|
||||
if cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info; then
|
||||
print_success "LCOV format generated as lcov.info"
|
||||
fi
|
||||
else
|
||||
print_error "Coverage generation failed"
|
||||
((TESTS_FAILED++))
|
||||
FAILED_TESTS+=("coverage generation")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Test different feature configurations
|
||||
print_status "Testing different feature configurations..."
|
||||
|
||||
run_test "minimal features" "cargo test --no-default-features $CARGO_OUTPUT"
|
||||
run_test "all features" "cargo test --all-features $CARGO_OUTPUT"
|
||||
|
||||
# Check that package builds for release
|
||||
print_status "Verifying release build..."
|
||||
run_test "release build" "cargo build --release --all-features $CARGO_OUTPUT"
|
||||
|
||||
# Verify package can be published (dry run)
|
||||
print_status "Verifying package..."
|
||||
run_test "package verification" "cargo package --dry-run $CARGO_OUTPUT"
|
||||
|
||||
# Print final results
|
||||
echo ""
|
||||
print_status "============ Test Results Summary ============"
|
||||
|
||||
if [ $TESTS_FAILED -eq 0 ]; then
|
||||
print_success "All tests passed! ($TESTS_PASSED/$((TESTS_PASSED + TESTS_FAILED)))"
|
||||
echo ""
|
||||
print_status "Ready for deployment! 🚀"
|
||||
exit 0
|
||||
else
|
||||
print_error "Some tests failed! ($TESTS_PASSED passed, $TESTS_FAILED failed)"
|
||||
echo ""
|
||||
print_error "Failed tests:"
|
||||
for test in "${FAILED_TESTS[@]}"; do
|
||||
echo -e " ${RED}✗${NC} $test"
|
||||
done
|
||||
echo ""
|
||||
print_error "Please fix the failing tests before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1 @@
|
||||
# py-docx-mcp: Python MCP server for DOCX document manipulation
|
||||
@@ -1,824 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use docx_rs::*;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use base64;
|
||||
|
||||
/// Advanced DOCX manipulation features
|
||||
pub struct AdvancedDocxHandler;
|
||||
|
||||
impl AdvancedDocxHandler {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Create a document with professional template
|
||||
pub fn create_from_template(&self, template_type: DocumentTemplate) -> Result<Docx> {
|
||||
let mut docx = Docx::new();
|
||||
|
||||
match template_type {
|
||||
DocumentTemplate::BusinessLetter => {
|
||||
docx = self.apply_business_letter_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Resume => {
|
||||
docx = self.apply_resume_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Report => {
|
||||
docx = self.apply_report_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Invoice => {
|
||||
docx = self.apply_invoice_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Contract => {
|
||||
docx = self.apply_contract_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Memo => {
|
||||
docx = self.apply_memo_template(docx)?;
|
||||
}
|
||||
DocumentTemplate::Newsletter => {
|
||||
docx = self.apply_newsletter_template(docx)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
/// Add a table of contents
|
||||
pub fn add_table_of_contents(&self, docx: Docx) -> Result<Docx> {
|
||||
let toc = TableOfContents::new()
|
||||
.heading_text("Table of Contents")
|
||||
.heading_style("TOCHeading");
|
||||
|
||||
let mut docx = docx.add_table_of_contents(toc);
|
||||
|
||||
// Add instruction text
|
||||
let instruction = Paragraph::new()
|
||||
.add_run(
|
||||
Run::new()
|
||||
.add_text("Right-click and select 'Update Field' to refresh the table of contents")
|
||||
.italic()
|
||||
.size(20)
|
||||
.color("808080")
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(instruction);
|
||||
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_break(BreakType::Page)));
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
/// Add an image to the document
|
||||
pub fn add_image(
|
||||
&self,
|
||||
docx: Docx,
|
||||
image_data: &[u8],
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
alt_text: Option<&str>
|
||||
) -> Result<Docx> {
|
||||
// Convert pixels to EMUs (English Metric Units)
|
||||
// 1 pixel = 9525 EMUs
|
||||
let width_emu = width_px * 9525;
|
||||
let height_emu = height_px * 9525;
|
||||
|
||||
let pic = Pic::new_with_dimensions(image_data.to_vec(), width_px, height_px);
|
||||
let drawing = Drawing::new().pic(pic);
|
||||
let paragraph = Paragraph::new().add_run(Run::new().add_drawing(drawing));
|
||||
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add a chart to the document
|
||||
pub fn add_chart(&self, docx: Docx, chart_type: ChartType, data: ChartData) -> Result<Docx> {
|
||||
// Charts in DOCX are complex and usually require embedding Excel data
|
||||
// For now, we'll create a table representation
|
||||
let mut table = Table::new(vec![]);
|
||||
|
||||
// Add headers
|
||||
let mut header_cells = vec![TableCell::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text("Category").bold())
|
||||
)];
|
||||
|
||||
for series in &data.series {
|
||||
header_cells.push(
|
||||
TableCell::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text(&series.name).bold())
|
||||
)
|
||||
);
|
||||
}
|
||||
table = table.add_row(TableRow::new(header_cells));
|
||||
|
||||
// Add data rows
|
||||
for (i, category) in data.categories.iter().enumerate() {
|
||||
let mut row_cells = vec![TableCell::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text(category))
|
||||
)];
|
||||
|
||||
for series in &data.series {
|
||||
if let Some(value) = series.values.get(i) {
|
||||
row_cells.push(
|
||||
TableCell::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text(&value.to_string()))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
table = table.add_row(TableRow::new(row_cells));
|
||||
}
|
||||
|
||||
// Add title for the chart
|
||||
let title = Paragraph::new()
|
||||
.add_run(Run::new().add_text(&format!("{:?}: {}", chart_type, data.title)).bold())
|
||||
.align(AlignmentType::Center);
|
||||
|
||||
Ok(docx.add_paragraph(title).add_table(table))
|
||||
}
|
||||
|
||||
/// Add a hyperlink
|
||||
pub fn add_hyperlink(&self, docx: Docx, text: &str, url: &str) -> Result<Docx> {
|
||||
let hyperlink = Hyperlink::new(url, HyperlinkType::External)
|
||||
.add_run(Run::new().add_text(text).color("0000FF").underline("single"));
|
||||
|
||||
let paragraph = Paragraph::new().add_hyperlink(hyperlink);
|
||||
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add a bookmark
|
||||
pub fn add_bookmark(&self, docx: Docx, bookmark_name: &str, text: &str) -> Result<Docx> {
|
||||
let bookmark_id = Uuid::new_v4().to_string();
|
||||
|
||||
let bookmark_start = BookmarkStart::new(&bookmark_id, bookmark_name);
|
||||
let bookmark_end = BookmarkEnd::new(&bookmark_id);
|
||||
|
||||
let paragraph = Paragraph::new()
|
||||
.add_bookmark_start(bookmark_start)
|
||||
.add_run(Run::new().add_text(text))
|
||||
.add_bookmark_end(bookmark_end);
|
||||
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add a cross-reference
|
||||
pub fn add_cross_reference(&self, docx: Docx, bookmark_name: &str, display_text: &str) -> Result<Docx> {
|
||||
// Cross-references in DOCX use field codes
|
||||
// Complex field support is limited in current docx-rs; fallback to plain hyperlink
|
||||
let paragraph = Paragraph::new().add_run(
|
||||
Run::new().add_text(display_text).add_hyperlink(Hyperlink::new(bookmark_name, HyperlinkType::External))
|
||||
);
|
||||
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add document properties and metadata
|
||||
pub fn set_document_properties(&self, docx: Docx, properties: DocumentProperties) -> Result<Docx> {
|
||||
let docx = docx
|
||||
.title(&properties.title)
|
||||
.subject(&properties.subject)
|
||||
.creator(&properties.author)
|
||||
.keywords(&properties.keywords.join(", "))
|
||||
.description(&properties.description);
|
||||
|
||||
if let Some(company) = properties.company {
|
||||
docx.company(&company);
|
||||
}
|
||||
|
||||
if let Some(manager) = properties.manager {
|
||||
docx.manager(&manager);
|
||||
}
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
/// Add a custom styled section
|
||||
pub fn add_section(&self, docx: Docx, section_config: SectionConfig) -> Result<Docx> {
|
||||
let mut section = SectionProperty::new();
|
||||
|
||||
// Page size
|
||||
match section_config.page_size {
|
||||
PageSize::A4 => {
|
||||
section = section.page_size(11906, 16838); // A4 in twips
|
||||
}
|
||||
PageSize::Letter => {
|
||||
section = section.page_size(12240, 15840); // Letter in twips
|
||||
}
|
||||
PageSize::Legal => {
|
||||
section = section.page_size(12240, 20160); // Legal in twips
|
||||
}
|
||||
PageSize::A3 => {
|
||||
section = section.page_size(16838, 23811); // A3 in twips
|
||||
}
|
||||
}
|
||||
|
||||
// Orientation
|
||||
if section_config.landscape {
|
||||
section = section.page_size(
|
||||
section.page_size.1,
|
||||
section.page_size.0
|
||||
);
|
||||
}
|
||||
|
||||
// Margins (convert mm to twips: 1mm = 56.7 twips)
|
||||
section = section.page_margin(
|
||||
PageMargin::new()
|
||||
.top((section_config.margins.top * 56.7) as i32)
|
||||
.bottom((section_config.margins.bottom * 56.7) as i32)
|
||||
.left((section_config.margins.left * 56.7) as i32)
|
||||
.right((section_config.margins.right * 56.7) as i32)
|
||||
.header((section_config.margins.header * 56.7) as i32)
|
||||
.footer((section_config.margins.footer * 56.7) as i32)
|
||||
);
|
||||
|
||||
// Columns
|
||||
if section_config.columns > 1 {
|
||||
section = section.columns(section_config.columns);
|
||||
}
|
||||
|
||||
Ok(docx.add_section(section))
|
||||
}
|
||||
|
||||
/// Add a watermark
|
||||
pub fn add_watermark(&self, docx: Docx, text: &str, style: WatermarkStyle) -> Result<Docx> {
|
||||
let watermark = match style {
|
||||
WatermarkStyle::Diagonal => {
|
||||
Run::new()
|
||||
.add_text(text)
|
||||
.size(144) // Large size
|
||||
.color("C0C0C0") // Light gray
|
||||
.bold()
|
||||
}
|
||||
WatermarkStyle::Horizontal => {
|
||||
Run::new()
|
||||
.add_text(text)
|
||||
.size(100)
|
||||
.color("E0E0E0")
|
||||
}
|
||||
};
|
||||
|
||||
// Watermarks are typically added to headers
|
||||
let header = Header::new().add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(watermark)
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
Ok(docx.header(header))
|
||||
}
|
||||
|
||||
/// Add footnote
|
||||
pub fn add_footnote(&self, docx: Docx, reference_text: &str, footnote_text: &str) -> Result<Docx> {
|
||||
let footnote_id = Uuid::new_v4().to_string();
|
||||
|
||||
// docx-rs footnote APIs are in flux; append note text inline as fallback
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(reference_text))
|
||||
.add_run(Run::new().add_text(format!(" [{}]", footnote_text)));
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add endnote
|
||||
pub fn add_endnote(&self, docx: Docx, reference_text: &str, endnote_text: &str) -> Result<Docx> {
|
||||
let endnote_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Fallback inline rendering for endnotes
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(reference_text))
|
||||
.add_run(Run::new().add_text(format!(" [{}]", endnote_text)));
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
/// Add custom styles
|
||||
pub fn add_custom_style(&self, docx: Docx, style: CustomStyle) -> Result<Docx> {
|
||||
let style_def = Style::new(&style.id, StyleType::Paragraph)
|
||||
.name(&style.name)
|
||||
.based_on(&style.based_on.unwrap_or_else(|| "Normal".to_string()));
|
||||
|
||||
let mut paragraph_property = ParagraphProperty::new();
|
||||
|
||||
if let Some(spacing) = style.spacing {
|
||||
use docx_rs::types::line_spacing_type::LineSpacingType;
|
||||
paragraph_property = paragraph_property
|
||||
.line_spacing(LineSpacing::new(spacing.line).line_rule(LineSpacingType::Auto));
|
||||
}
|
||||
|
||||
if let Some(indent) = style.indent {
|
||||
paragraph_property = paragraph_property
|
||||
.indent(Some(indent.left), Some(indent.right), Some(indent.first_line), None);
|
||||
}
|
||||
|
||||
let mut run_property = RunProperty::new();
|
||||
|
||||
if let Some(font) = style.font {
|
||||
run_property = run_property.fonts(RunFonts::new().ascii(&font).east_asia(&font));
|
||||
}
|
||||
|
||||
if let Some(size) = style.size {
|
||||
run_property = run_property.size(size);
|
||||
}
|
||||
|
||||
if style.bold {
|
||||
run_property = run_property.bold();
|
||||
}
|
||||
|
||||
if style.italic {
|
||||
run_property = run_property.italic();
|
||||
}
|
||||
|
||||
if let Some(color) = style.color {
|
||||
run_property = run_property.color(&color);
|
||||
}
|
||||
|
||||
let style_def = style_def
|
||||
.paragraph_property(paragraph_property)
|
||||
.run_property(run_property);
|
||||
|
||||
Ok(docx.add_style(style_def))
|
||||
}
|
||||
|
||||
/// Mail merge functionality
|
||||
pub fn prepare_mail_merge_template(&self, docx: Docx, fields: Vec<String>) -> Result<Docx> {
|
||||
let mut docx = docx;
|
||||
|
||||
for field in fields {
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(format!("«{}»", field)));
|
||||
|
||||
docx = docx.add_paragraph(paragraph);
|
||||
}
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
/// Add comments (annotations)
|
||||
pub fn add_comment(&self, docx: Docx, text: &str, comment: &str, author: &str) -> Result<Docx> {
|
||||
let comment_id = Uuid::new_v4().to_string();
|
||||
let date = Utc::now();
|
||||
|
||||
// Fallback: inline annotation style rendering (no true comment element)
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(text))
|
||||
.add_run(Run::new().add_text(format!(" [Comment by {}: {}]", author, comment)));
|
||||
Ok(docx.add_paragraph(paragraph))
|
||||
}
|
||||
|
||||
// Template helper methods
|
||||
|
||||
fn apply_business_letter_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Add sender info placeholder
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Your Name]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Your Address]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[City, State ZIP]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Your Email]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Your Phone]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Date
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Date]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Recipient info
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Recipient Name]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Title]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Company]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[Address]"))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_text("[City, State ZIP]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Salutation
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("Dear [Recipient Name]:"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Body placeholder
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Letter body paragraph 1]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Letter body paragraph 2]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Letter body paragraph 3]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Closing
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("Sincerely,"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Your Name]"))
|
||||
);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_resume_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Name header
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[YOUR NAME]").size(32).bold())
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
// Contact info
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Email] | [Phone] | [LinkedIn] | [Location]").size(22))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text("").size(12)));
|
||||
|
||||
// Professional Summary
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("PROFESSIONAL SUMMARY").size(24).bold())
|
||||
.style("Heading2")
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[2-3 lines summarizing your experience and key skills]"))
|
||||
);
|
||||
|
||||
// Experience
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("EXPERIENCE").size(24).bold())
|
||||
.style("Heading2")
|
||||
);
|
||||
|
||||
// Education
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("EDUCATION").size(24).bold())
|
||||
.style("Heading2")
|
||||
);
|
||||
|
||||
// Skills
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("SKILLS").size(24).bold())
|
||||
.style("Heading2")
|
||||
);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_report_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Title page
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text(""))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[REPORT TITLE]").size(36).bold())
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Subtitle or Description]").size(24))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
.add_run(Run::new().add_break(BreakType::TextWrapping))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("Prepared by:").size(20))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Author Name]").size(20))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Date]").size(20))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
// Page break
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_break(BreakType::Page))
|
||||
);
|
||||
|
||||
// Table of Contents placeholder
|
||||
docx = self.add_table_of_contents(docx)?;
|
||||
|
||||
// Executive Summary
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("Executive Summary").size(28).bold())
|
||||
.style("Heading1")
|
||||
);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_invoice_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Company header
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[COMPANY NAME]").size(32).bold())
|
||||
.align(AlignmentType::Right)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("INVOICE").size(28).bold())
|
||||
.align(AlignmentType::Right)
|
||||
);
|
||||
|
||||
// Invoice details table
|
||||
let invoice_info = Table::new(vec![
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Invoice #:"))),
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[INV-0001]"))),
|
||||
])
|
||||
.add_row(TableRow::new(vec![
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Date:"))),
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Date]"))),
|
||||
]))
|
||||
.add_row(TableRow::new(vec![
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("Due Date:"))),
|
||||
TableCell::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text("[Due Date]"))),
|
||||
]));
|
||||
|
||||
docx = docx.add_table(invoice_info);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_contract_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Contract title
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[CONTRACT TYPE] AGREEMENT").size(28).bold())
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Parties
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("This Agreement is entered into as of [Date] between:"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Party 1 Name], a [Entity Type] (\"Party 1\")"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("and"))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Party 2 Name], a [Entity Type] (\"Party 2\")"))
|
||||
);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_memo_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Memo header
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("MEMORANDUM").size(24).bold())
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(Paragraph::new());
|
||||
|
||||
// Memo fields
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("TO: ").bold())
|
||||
.add_run(Run::new().add_text("[Recipient(s)]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("FROM: ").bold())
|
||||
.add_run(Run::new().add_text("[Sender]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("DATE: ").bold())
|
||||
.add_run(Run::new().add_text("[Date]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("SUBJECT: ").bold())
|
||||
.add_run(Run::new().add_text("[Subject]"))
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("_").repeat(70))
|
||||
);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
|
||||
fn apply_newsletter_template(&self, mut docx: Docx) -> Result<Docx> {
|
||||
// Newsletter header
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[NEWSLETTER TITLE]").size(36).bold())
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
docx = docx.add_paragraph(
|
||||
Paragraph::new()
|
||||
.add_run(Run::new().add_text("[Issue #] | [Date]").size(18))
|
||||
.align(AlignmentType::Center)
|
||||
);
|
||||
|
||||
// Two-column layout simulation
|
||||
let columns = SectionProperty::new().columns(2);
|
||||
docx = docx.add_section(columns);
|
||||
|
||||
Ok(docx)
|
||||
}
|
||||
}
|
||||
|
||||
// Supporting types
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DocumentTemplate {
|
||||
BusinessLetter,
|
||||
Resume,
|
||||
Report,
|
||||
Invoice,
|
||||
Contract,
|
||||
Memo,
|
||||
Newsletter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocumentProperties {
|
||||
pub title: String,
|
||||
pub subject: String,
|
||||
pub author: String,
|
||||
pub keywords: Vec<String>,
|
||||
pub description: String,
|
||||
pub company: Option<String>,
|
||||
pub manager: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SectionConfig {
|
||||
pub page_size: PageSize,
|
||||
pub landscape: bool,
|
||||
pub margins: Margins,
|
||||
pub columns: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PageSize {
|
||||
A4,
|
||||
Letter,
|
||||
Legal,
|
||||
A3,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Margins {
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
pub header: f32,
|
||||
pub footer: f32,
|
||||
}
|
||||
|
||||
impl Default for Margins {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
top: 25.4, // 1 inch in mm
|
||||
bottom: 25.4,
|
||||
left: 25.4,
|
||||
right: 25.4,
|
||||
header: 12.7, // 0.5 inch
|
||||
footer: 12.7,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
Column,
|
||||
Line,
|
||||
Pie,
|
||||
Area,
|
||||
Scatter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub title: String,
|
||||
pub categories: Vec<String>,
|
||||
pub series: Vec<ChartSeries>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartSeries {
|
||||
pub name: String,
|
||||
pub values: Vec<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum WatermarkStyle {
|
||||
Diagonal,
|
||||
Horizontal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomStyle {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub based_on: Option<String>,
|
||||
pub font: Option<String>,
|
||||
pub size: Option<usize>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
pub color: Option<String>,
|
||||
pub spacing: Option<StyleSpacing>,
|
||||
pub indent: Option<StyleIndent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StyleSpacing {
|
||||
pub before: i32,
|
||||
pub after: i32,
|
||||
pub line: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StyleIndent {
|
||||
pub left: i32,
|
||||
pub right: i32,
|
||||
pub first_line: i32,
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use ::image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use printpdf::*;
|
||||
use lopdf::{self, dictionary, Object, ObjectId, Document as LoDocument};
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::pure_converter::PureRustConverter;
|
||||
|
||||
pub struct DocumentConverter {
|
||||
pure_converter: PureRustConverter,
|
||||
prefer_external_tools: bool,
|
||||
}
|
||||
|
||||
impl DocumentConverter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pure_converter: PureRustConverter::new(),
|
||||
prefer_external_tools: false, // Default to pure Rust implementation
|
||||
}
|
||||
}
|
||||
|
||||
pub fn docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
|
||||
if self.prefer_external_tools {
|
||||
// Try external tools first if preferred
|
||||
// Method 1: Try LibreOffice if available
|
||||
if self.try_libreoffice_conversion(docx_path, pdf_path).is_ok() {
|
||||
info!("Successfully converted DOCX to PDF using LibreOffice");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Method 2: Try unoconv if available
|
||||
if self.try_unoconv_conversion(docx_path, pdf_path).is_ok() {
|
||||
info!("Successfully converted DOCX to PDF using unoconv");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Use pure Rust implementation (default)
|
||||
self.pure_converter.docx_to_pdf_pure(docx_path, pdf_path)?;
|
||||
info!("Successfully converted DOCX to PDF using pure Rust implementation");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_libreoffice_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
|
||||
let output = Command::new("libreoffice")
|
||||
.args(&[
|
||||
"--headless",
|
||||
"--invisible",
|
||||
"--nodefault",
|
||||
"--nolockcheck",
|
||||
"--nologo",
|
||||
"--norestore",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
pdf_path.parent().unwrap().to_str().unwrap(),
|
||||
docx_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
// LibreOffice creates the PDF with the same base name
|
||||
let temp_pdf = pdf_path.parent().unwrap()
|
||||
.join(docx_path.file_stem().unwrap())
|
||||
.with_extension("pdf");
|
||||
|
||||
if temp_pdf != pdf_path {
|
||||
fs::rename(&temp_pdf, pdf_path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("LibreOffice conversion failed: {}", stderr)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("LibreOffice not available: {}", e);
|
||||
anyhow::bail!("LibreOffice not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_unoconv_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
|
||||
let output = Command::new("unoconv")
|
||||
.args(&[
|
||||
"-f", "pdf",
|
||||
"-o", pdf_path.to_str().unwrap(),
|
||||
docx_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("unoconv conversion failed: {}", stderr)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("unoconv not available: {}", e);
|
||||
anyhow::bail!("unoconv not available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn basic_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
|
||||
// Extract text from DOCX (fallback using dotext)
|
||||
let mut reader = dotext::Docx::open(docx_path)
|
||||
.with_context(|| format!("Failed to open DOCX {:?}", docx_path))?;
|
||||
let mut data = String::new();
|
||||
use std::io::Read as _;
|
||||
reader.read_to_string(&mut data)?;
|
||||
let text = data;
|
||||
|
||||
// Create a basic PDF with the extracted text
|
||||
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
|
||||
let current_layer = doc.get_page(page1).get_layer(layer1);
|
||||
|
||||
// Load a basic font
|
||||
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
|
||||
|
||||
// Split text into lines and add to PDF
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut y_position = Mm(280.0);
|
||||
let line_height = Mm(5.0);
|
||||
|
||||
for line in lines {
|
||||
if y_position < Mm(20.0) {
|
||||
// Add new page if needed
|
||||
let (page, layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
|
||||
let current_layer = doc.get_page(page).get_layer(layer);
|
||||
y_position = Mm(280.0);
|
||||
}
|
||||
|
||||
current_layer.use_text(line, 12.0, Mm(10.0), y_position, &font);
|
||||
y_position -= line_height;
|
||||
}
|
||||
|
||||
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn pdf_to_images(
|
||||
&self,
|
||||
pdf_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
dpi: u32,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
// Try multiple methods for PDF to image conversion
|
||||
|
||||
// Method 1: Try pdftoppm if available
|
||||
if let Ok(images) = self.try_pdftoppm_conversion(pdf_path, output_dir, format, dpi) {
|
||||
info!("Successfully converted PDF to images using pdftoppm");
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
// Method 2: Try ImageMagick if available
|
||||
if let Ok(images) = self.try_imagemagick_conversion(pdf_path, output_dir, format, dpi) {
|
||||
info!("Successfully converted PDF to images using ImageMagick");
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
// Method 3: Try Ghostscript if available
|
||||
if let Ok(images) = self.try_ghostscript_conversion(pdf_path, output_dir, format, dpi) {
|
||||
info!("Successfully converted PDF to images using Ghostscript");
|
||||
return Ok(images);
|
||||
}
|
||||
|
||||
anyhow::bail!("No PDF to image converter available. Please install pdftoppm, ImageMagick, or Ghostscript")
|
||||
}
|
||||
|
||||
fn try_pdftoppm_conversion(
|
||||
&self,
|
||||
pdf_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
dpi: u32,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let output_prefix = output_dir.join("page");
|
||||
let format_arg = match format {
|
||||
ImageFormat::Png => "-png",
|
||||
ImageFormat::Jpeg => "-jpeg",
|
||||
_ => "-png",
|
||||
};
|
||||
|
||||
let output = Command::new("pdftoppm")
|
||||
.args(&[
|
||||
format_arg,
|
||||
"-r", &dpi.to_string(),
|
||||
pdf_path.to_str().unwrap(),
|
||||
output_prefix.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("pdftoppm failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Collect generated image files
|
||||
let extension = match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
_ => "png",
|
||||
};
|
||||
|
||||
let mut images = Vec::new();
|
||||
for entry in fs::read_dir(output_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
|
||||
images.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
images.sort();
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
fn try_imagemagick_conversion(
|
||||
&self,
|
||||
pdf_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
dpi: u32,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let extension = match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
_ => "png",
|
||||
};
|
||||
|
||||
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
|
||||
|
||||
let output = Command::new("convert")
|
||||
.args(&[
|
||||
"-density", &dpi.to_string(),
|
||||
pdf_path.to_str().unwrap(),
|
||||
"-quality", "100",
|
||||
output_pattern.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("ImageMagick convert failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Collect generated image files
|
||||
let mut images = Vec::new();
|
||||
for entry in fs::read_dir(output_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
|
||||
images.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
images.sort();
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
fn try_ghostscript_conversion(
|
||||
&self,
|
||||
pdf_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
dpi: u32,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let device = match format {
|
||||
ImageFormat::Png => "png16m",
|
||||
ImageFormat::Jpeg => "jpeg",
|
||||
_ => "png16m",
|
||||
};
|
||||
|
||||
let extension = match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
_ => "png",
|
||||
};
|
||||
|
||||
let output_pattern = output_dir.join(format!("page-%03d.{}", extension));
|
||||
|
||||
let output = Command::new("gs")
|
||||
.args(&[
|
||||
"-dNOPAUSE",
|
||||
"-dBATCH",
|
||||
"-sDEVICE", device,
|
||||
&format!("-r{}", dpi),
|
||||
"-dTextAlphaBits=4",
|
||||
"-dGraphicsAlphaBits=4",
|
||||
&format!("-sOutputFile={}", output_pattern.to_str().unwrap()),
|
||||
pdf_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Ghostscript failed: {}", stderr);
|
||||
}
|
||||
|
||||
// Collect generated image files
|
||||
let mut images = Vec::new();
|
||||
for entry in fs::read_dir(output_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension() == Some(std::ffi::OsStr::new(extension)) {
|
||||
images.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
images.sort();
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
pub fn docx_to_images(
|
||||
&self,
|
||||
docx_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
dpi: u32,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
// First convert DOCX to PDF
|
||||
let temp_pdf = NamedTempFile::new()?.into_temp_path();
|
||||
self.docx_to_pdf(docx_path, &temp_pdf)?;
|
||||
|
||||
// Then convert PDF to images
|
||||
let images = self.pdf_to_images(&temp_pdf, output_dir, format, dpi)?;
|
||||
|
||||
Ok(images)
|
||||
}
|
||||
|
||||
pub fn create_thumbnail(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<()> {
|
||||
let img = ::image::open(image_path)
|
||||
.with_context(|| format!("Failed to open image {:?}", image_path))?;
|
||||
|
||||
let thumbnail = img.thumbnail(width, height);
|
||||
thumbnail.save(output_path)
|
||||
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
|
||||
|
||||
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn merge_pdfs(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
|
||||
// Try using pdftk if available
|
||||
if self.try_pdftk_merge(pdf_paths, output_path).is_ok() {
|
||||
info!("Successfully merged PDFs using pdftk");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fallback to lopdf for merging
|
||||
self.merge_pdfs_with_lopdf(pdf_paths, output_path)?;
|
||||
info!("Successfully merged PDFs using lopdf");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_pdftk_merge(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
|
||||
let mut args = Vec::new();
|
||||
for path in pdf_paths {
|
||||
args.push(path.to_str().unwrap());
|
||||
}
|
||||
args.push("cat");
|
||||
args.push("output");
|
||||
args.push(output_path.to_str().unwrap());
|
||||
|
||||
let output = Command::new("pdftk")
|
||||
.args(&args)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("pdftk merge failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_pdfs_with_lopdf(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
|
||||
let mut merged = LoDocument::new();
|
||||
merged.version = "1.5".to_string();
|
||||
|
||||
for pdf_path in pdf_paths {
|
||||
let mut doc = LoDocument::load(pdf_path)?;
|
||||
|
||||
// Merge pages
|
||||
for page_id in doc.get_pages().values() {
|
||||
merged.add_object(doc.get_object(*page_id)?.clone());
|
||||
}
|
||||
}
|
||||
|
||||
merged.save(output_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn split_pdf(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let doc = LoDocument::load(pdf_path)?;
|
||||
let pages = doc.get_pages();
|
||||
let mut output_paths = Vec::new();
|
||||
|
||||
for (i, (_, page_id)) in pages.iter().enumerate() {
|
||||
let mut single_page = LoDocument::new();
|
||||
single_page.version = doc.version.clone();
|
||||
|
||||
// Clone the page to the new document
|
||||
single_page.add_object(doc.get_object(*page_id)?.clone());
|
||||
|
||||
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
|
||||
single_page.save(&output_path)?;
|
||||
output_paths.push(output_path);
|
||||
}
|
||||
|
||||
info!("Split PDF into {} pages", output_paths.len());
|
||||
Ok(output_paths)
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use docx_rs::*;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
use uuid::Uuid;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocxMetadata {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
pub size_bytes: u64,
|
||||
pub page_count: Option<usize>,
|
||||
pub word_count: Option<usize>,
|
||||
pub author: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocxStyle {
|
||||
pub font_family: Option<String>,
|
||||
pub font_size: Option<usize>,
|
||||
pub bold: Option<bool>,
|
||||
pub italic: Option<bool>,
|
||||
pub underline: Option<bool>,
|
||||
pub color: Option<String>,
|
||||
pub alignment: Option<String>,
|
||||
pub line_spacing: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TableData {
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub headers: Option<Vec<String>>,
|
||||
pub border_style: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageData {
|
||||
pub data: Vec<u8>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub alt_text: Option<String>,
|
||||
}
|
||||
|
||||
pub struct DocxHandler {
|
||||
temp_dir: PathBuf,
|
||||
pub documents: std::collections::HashMap<String, DocxMetadata>,
|
||||
}
|
||||
|
||||
impl DocxHandler {
|
||||
pub fn new() -> Result<Self> {
|
||||
let temp_dir = std::env::temp_dir().join("docx-mcp");
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
temp_dir,
|
||||
documents: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_temp_dir(temp_dir: &Path) -> Result<Self> {
|
||||
let temp_dir = temp_dir.to_path_buf();
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
Ok(Self {
|
||||
temp_dir,
|
||||
documents: std::collections::HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_document(&mut self) -> Result<String> {
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let doc_path = self.temp_dir.join(format!("{}.docx", doc_id));
|
||||
|
||||
let docx = Docx::new();
|
||||
let file = File::create(&doc_path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
let metadata = DocxMetadata {
|
||||
id: doc_id.clone(),
|
||||
path: doc_path,
|
||||
created_at: Utc::now(),
|
||||
modified_at: Utc::now(),
|
||||
size_bytes: 0,
|
||||
page_count: Some(1),
|
||||
word_count: Some(0),
|
||||
author: None,
|
||||
title: None,
|
||||
subject: None,
|
||||
};
|
||||
|
||||
self.documents.insert(doc_id.clone(), metadata);
|
||||
info!("Created new document with ID: {}", doc_id);
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
pub fn open_document(&mut self, path: &Path) -> Result<String> {
|
||||
let doc_id = Uuid::new_v4().to_string();
|
||||
let doc_path = self.temp_dir.join(format!("{}.docx", doc_id));
|
||||
|
||||
fs::copy(path, &doc_path)
|
||||
.with_context(|| format!("Failed to copy document from {:?}", path))?;
|
||||
|
||||
let file_metadata = fs::metadata(&doc_path)?;
|
||||
|
||||
let metadata = DocxMetadata {
|
||||
id: doc_id.clone(),
|
||||
path: doc_path,
|
||||
created_at: Utc::now(),
|
||||
modified_at: Utc::now(),
|
||||
size_bytes: file_metadata.len(),
|
||||
page_count: None,
|
||||
word_count: None,
|
||||
author: None,
|
||||
title: None,
|
||||
subject: None,
|
||||
};
|
||||
|
||||
self.documents.insert(doc_id.clone(), metadata);
|
||||
info!("Opened document from {:?} with ID: {}", path, doc_id);
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
pub fn add_paragraph(&mut self, doc_id: &str, text: &str, style: Option<DocxStyle>) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let mut paragraph = Paragraph::new().add_run(Run::new().add_text(text));
|
||||
|
||||
if let Some(style) = style {
|
||||
let mut run = Run::new().add_text(text);
|
||||
|
||||
if let Some(size) = style.font_size {
|
||||
run = run.size(size);
|
||||
}
|
||||
if style.bold == Some(true) {
|
||||
run = run.bold();
|
||||
}
|
||||
if style.italic == Some(true) {
|
||||
run = run.italic();
|
||||
}
|
||||
if style.underline == Some(true) {
|
||||
run = run.underline("single");
|
||||
}
|
||||
if let Some(color) = style.color {
|
||||
run = run.color(color);
|
||||
}
|
||||
|
||||
paragraph = Paragraph::new().add_run(run);
|
||||
|
||||
if let Some(alignment) = style.alignment {
|
||||
paragraph = match alignment.as_str() {
|
||||
"left" => paragraph.align(AlignmentType::Left),
|
||||
"center" => paragraph.align(AlignmentType::Center),
|
||||
"right" => paragraph.align(AlignmentType::Right),
|
||||
"justify" => paragraph.align(AlignmentType::Justified),
|
||||
_ => paragraph,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
docx = docx.add_paragraph(paragraph);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Added paragraph to document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_heading(&mut self, doc_id: &str, text: &str, level: usize) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let heading_style = match level {
|
||||
1 => "Heading1",
|
||||
2 => "Heading2",
|
||||
3 => "Heading3",
|
||||
4 => "Heading4",
|
||||
5 => "Heading5",
|
||||
6 => "Heading6",
|
||||
_ => "Heading1",
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(text))
|
||||
.style(heading_style);
|
||||
|
||||
docx = docx.add_paragraph(paragraph);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Added heading level {} to document {}", level, doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_table(&mut self, doc_id: &str, table_data: TableData) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let col_count = table_data.rows.get(0).map(|r| r.len()).unwrap_or(0);
|
||||
let mut table = Table::new(vec![TableCell::new(); col_count]);
|
||||
|
||||
for row_data in table_data.rows {
|
||||
let mut cells = Vec::new();
|
||||
for cell_text in row_data {
|
||||
let cell = TableCell::new()
|
||||
.add_paragraph(Paragraph::new().add_run(Run::new().add_text(cell_text)));
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
while cells.len() < col_count {
|
||||
cells.push(TableCell::new());
|
||||
}
|
||||
|
||||
table = table.add_row(TableRow::new(cells));
|
||||
}
|
||||
|
||||
docx = docx.add_table(table);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Added table to document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_list(&mut self, doc_id: &str, items: Vec<String>, ordered: bool) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let numbering_id = if ordered { 1 } else { 2 };
|
||||
|
||||
for item in items {
|
||||
let paragraph = Paragraph::new()
|
||||
.add_run(Run::new().add_text(item))
|
||||
.numbering(NumberingId::new(numbering_id), IndentLevel::new(0));
|
||||
|
||||
docx = docx.add_paragraph(paragraph);
|
||||
}
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Added {} list to document {}", if ordered { "ordered" } else { "unordered" }, doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_page_break(&mut self, doc_id: &str) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let paragraph = Paragraph::new().add_run(Run::new().add_break(BreakType::Page));
|
||||
docx = docx.add_paragraph(paragraph);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Added page break to document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_header(&mut self, doc_id: &str, text: &str) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let header = Header::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text(text))
|
||||
);
|
||||
|
||||
docx = docx.header(header);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Set header for document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_footer(&mut self, doc_id: &str, text: &str) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
let mut file = File::open(&metadata.path)?;
|
||||
let mut buffer = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
let mut docx = Docx::from_reader(&buffer[..])?;
|
||||
|
||||
let footer = Footer::new().add_paragraph(
|
||||
Paragraph::new().add_run(Run::new().add_text(text))
|
||||
);
|
||||
|
||||
docx = docx.footer(footer);
|
||||
|
||||
let file = File::create(&metadata.path)?;
|
||||
docx.build().pack(file)?;
|
||||
|
||||
info!("Set footer for document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn find_and_replace(&mut self, doc_id: &str, find_text: &str, replace_text: &str) -> Result<usize> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
// Note: This is a simplified implementation
|
||||
// Real implementation would need to parse the DOCX XML structure
|
||||
// and perform replacements while preserving formatting
|
||||
|
||||
warn!("Find and replace operation requires advanced XML manipulation");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
pub fn extract_text(&self, doc_id: &str) -> Result<String> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
// Use pure Rust text extraction
|
||||
use crate::pure_converter::PureRustConverter;
|
||||
let converter = PureRustConverter::new();
|
||||
let text = converter.extract_text_from_docx(&metadata.path)
|
||||
.with_context(|| format!("Failed to extract text from document {}", doc_id))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn get_metadata(&self, doc_id: &str) -> Result<DocxMetadata> {
|
||||
self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))
|
||||
.map(|m| m.clone())
|
||||
}
|
||||
|
||||
pub fn save_document(&self, doc_id: &str, output_path: &Path) -> Result<()> {
|
||||
let metadata = self.documents.get(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
fs::copy(&metadata.path, output_path)
|
||||
.with_context(|| format!("Failed to save document to {:?}", output_path))?;
|
||||
|
||||
info!("Saved document {} to {:?}", doc_id, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn close_document(&mut self, doc_id: &str) -> Result<()> {
|
||||
let metadata = self.documents.remove(doc_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?;
|
||||
|
||||
if metadata.path.exists() {
|
||||
fs::remove_file(&metadata.path)?;
|
||||
}
|
||||
|
||||
info!("Closed document {}", doc_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_documents(&self) -> Vec<DocxMetadata> {
|
||||
self.documents.values().cloned().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import regex as re_lib
|
||||
from docx import Document
|
||||
from docx.shared import Inches, Pt, RGBColor
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from PIL import Image as PILImage
|
||||
from io import BytesIO
|
||||
|
||||
from security import SecurityConfig
|
||||
|
||||
|
||||
def file_to_result(path: str, return_content: bool = False) -> Any:
|
||||
"""
|
||||
Helper: if return_content is True, read file and return {path, size, content_base64}.
|
||||
Otherwise return {path, size}.
|
||||
"""
|
||||
size = os.path.getsize(path)
|
||||
if not return_content:
|
||||
return {"path": path, "size": size}
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
b64 = base64.b64encode(data).decode("utf-8")
|
||||
return {"path": path, "size": len(data), "content_base64": b64}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentMetadata:
|
||||
document_id: str
|
||||
path: str
|
||||
name: str
|
||||
size: int
|
||||
pages: int
|
||||
|
||||
|
||||
class DocxToolsProvider:
|
||||
def __init__(
|
||||
self,
|
||||
security_config: SecurityConfig,
|
||||
templates_dir: str,
|
||||
):
|
||||
self.security_config = security_config
|
||||
self.templates_dir = templates_dir
|
||||
self.documents: Dict[str, Any] = {}
|
||||
self._temp_base = tempfile.mkdtemp(prefix="py_docx_mcp_")
|
||||
|
||||
# ---- basic lifecycle ----
|
||||
|
||||
def create_document(self) -> str:
|
||||
doc_id = str(uuid.uuid4())
|
||||
path = os.path.join(self._temp_base, f"{doc_id}.docx")
|
||||
doc = Document()
|
||||
doc.save(path)
|
||||
self.documents[doc_id] = {
|
||||
"doc": doc,
|
||||
"path": path,
|
||||
"name": "Untitled",
|
||||
}
|
||||
return doc_id
|
||||
|
||||
def open_document(self, path: str) -> str:
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError(f"File not found: {path}")
|
||||
doc_id = str(uuid.uuid4())
|
||||
doc = Document(path)
|
||||
self.documents[doc_id] = {
|
||||
"doc": doc,
|
||||
"path": path,
|
||||
"name": os.path.basename(path),
|
||||
}
|
||||
return doc_id
|
||||
|
||||
def get_doc(self, document_id: str) -> Document:
|
||||
entry = self.documents.get(document_id)
|
||||
if not entry:
|
||||
raise ValueError(f"Document not found: {document_id}")
|
||||
return entry["doc"]
|
||||
|
||||
def list_documents(self) -> List[Dict[str, Any]]:
|
||||
out = []
|
||||
for doc_id, info in self.documents.items():
|
||||
out.append({
|
||||
"document_id": doc_id,
|
||||
"name": info["name"],
|
||||
"path": info["path"],
|
||||
})
|
||||
return out
|
||||
|
||||
def close_document(self, document_id: str) -> None:
|
||||
if document_id not in self.documents:
|
||||
raise ValueError("Document not found")
|
||||
del self.documents[document_id]
|
||||
|
||||
# ---- content operations ----
|
||||
|
||||
def add_paragraph(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
style: Dict[str, Any],
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
p = doc.add_paragraph(text)
|
||||
run = p.runs[0] if p.runs else p.add_run()
|
||||
|
||||
font_family = style.get("font_family")
|
||||
font_size = style.get("font_size")
|
||||
bold = style.get("bold")
|
||||
italic = style.get("italic")
|
||||
underline = style.get("underline")
|
||||
color = style.get("color")
|
||||
alignment = style.get("alignment")
|
||||
|
||||
if font_family:
|
||||
run.font.name = font_family
|
||||
if font_size:
|
||||
run.font.size = Pt(font_size)
|
||||
if bold is not None:
|
||||
run.bold = bool(bold)
|
||||
if italic is not None:
|
||||
run.italic = bool(italic)
|
||||
if underline is not None:
|
||||
run.underline = bool(underline)
|
||||
if color:
|
||||
try:
|
||||
run.font.color.rgb = RGBColor.from_string(color)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if alignment:
|
||||
align = alignment.lower()
|
||||
if align == "center":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
elif align == "right":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
||||
elif align == "justify":
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_heading(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
level: int,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
level = max(0, min(6, level))
|
||||
doc.add_heading(text, level=level)
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_table(
|
||||
self,
|
||||
document_id: str,
|
||||
rows: List[List[str]],
|
||||
headers: Optional[List[str]] = None,
|
||||
border_style: Optional[str] = None,
|
||||
col_widths: Optional[List[int]] = None,
|
||||
cell_shading: Optional[str] = None,
|
||||
merges: Optional[List[Dict[str, int]]] = None,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
rows = rows or []
|
||||
if headers:
|
||||
rows = [headers] + rows
|
||||
if not rows:
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
table = doc.add_table(rows=len(rows), cols=len(rows[0]))
|
||||
for ri, row in enumerate(rows):
|
||||
for ci, val in enumerate(row):
|
||||
table.cell(ri, ci).text = str(val or "")
|
||||
|
||||
if merges:
|
||||
for m in merges:
|
||||
r = m.get("row", 0)
|
||||
c = m.get("col", 0)
|
||||
row_span = m.get("row_span", 1)
|
||||
col_span = m.get("col_span", 1)
|
||||
if row_span > 1 or col_span > 1:
|
||||
table.cell(r, c).merge(
|
||||
table.cell(r + row_span - 1, c + col_span - 1)
|
||||
)
|
||||
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_section_break(
|
||||
self,
|
||||
document_id: str,
|
||||
page_size: Optional[str] = None,
|
||||
orientation: Optional[str] = None,
|
||||
margins: Optional[Dict[str, float]] = None,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
doc.add_page_break()
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_list(
|
||||
self,
|
||||
document_id: str,
|
||||
items: List[str],
|
||||
ordered: bool,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
prefix = "1. " if ordered else "- "
|
||||
for item in items:
|
||||
doc.add_paragraph(f"{prefix}{item}")
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_list_item(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
level: int,
|
||||
ordered: bool,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
indent = " " * level
|
||||
prefix = "1. " if ordered else "- "
|
||||
doc.add_paragraph(f"{indent}{prefix}{text}")
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_page_break(
|
||||
self,
|
||||
document_id: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
doc.add_page_break()
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def insert_toc(
|
||||
self,
|
||||
document_id: str,
|
||||
from_level: int = 1,
|
||||
to_level: int = 3,
|
||||
right_align_dots: bool = True,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
doc.add_paragraph("Table of Contents (placeholder)")
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def insert_bookmark_after_heading(
|
||||
self,
|
||||
document_id: str,
|
||||
heading_text: str,
|
||||
name: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
# python-docx does not expose bookmarks easily; placeholder.
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def set_header(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
section = doc.sections[0]
|
||||
header = section.header
|
||||
header.paragraphs[0].text = text
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def set_footer(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
section = doc.sections[0]
|
||||
footer = section.footer
|
||||
footer.paragraphs[0].text = text
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def set_page_numbering(
|
||||
self,
|
||||
document_id: str,
|
||||
location: str,
|
||||
template: Optional[str] = None,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
section = doc.sections[0]
|
||||
target = section.footer if location == "footer" else section.header
|
||||
target.paragraphs[0].text = template or "Page {PAGE} of {PAGES}"
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def embed_page_number_fields(
|
||||
self,
|
||||
document_id: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
# python-docx cannot easily inject raw field codes; no-op placeholder.
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_image(
|
||||
self,
|
||||
document_id: str,
|
||||
data_base64: str,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
alt_text: Optional[str] = None,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
img_data = base64.b64decode(data_base64)
|
||||
img = PILImage.open(BytesIO(img_data))
|
||||
tmp_path = "/tmp/py_docx_temp.png"
|
||||
img.save(tmp_path, format="PNG")
|
||||
doc.add_picture(
|
||||
tmp_path,
|
||||
width=Inches(width / 96.0) if width else None,
|
||||
height=Inches(height / 96.0) if height else None,
|
||||
)
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def add_hyperlink(
|
||||
self,
|
||||
document_id: str,
|
||||
text: str,
|
||||
url: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
doc.add_paragraph(f"{text} ({url})")
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def find_and_replace(
|
||||
self,
|
||||
document_id: str,
|
||||
find_text: str,
|
||||
replace_text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
count = 0
|
||||
for para in doc.paragraphs:
|
||||
if find_text in para.text:
|
||||
para.text = para.text.replace(find_text, replace_text)
|
||||
count += para.text.count(replace_text)
|
||||
return {
|
||||
"success": True,
|
||||
"replacements": count,
|
||||
"document": self._maybe_return_doc(document_id, return_content),
|
||||
}
|
||||
|
||||
def find_and_replace_advanced(
|
||||
self,
|
||||
document_id: str,
|
||||
pattern: str,
|
||||
replacement: str,
|
||||
case_sensitive: bool,
|
||||
whole_word: bool,
|
||||
use_regex: bool,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
count = 0
|
||||
for para in doc.paragraphs:
|
||||
original = para.text
|
||||
if use_regex:
|
||||
flags = 0 if case_sensitive else re_lib.IGNORECASE
|
||||
pat = pattern
|
||||
else:
|
||||
if whole_word:
|
||||
pat = r"\b" + re_lib.escape(pattern) + r"\b"
|
||||
else:
|
||||
pat = re_lib.escape(pattern)
|
||||
flags = 0 if case_sensitive else re_lib.IGNORECASE
|
||||
|
||||
new_text, n = re_lib.subn(pat, replacement, original, flags=flags)
|
||||
if new_text != original:
|
||||
para.text = new_text
|
||||
count += n
|
||||
return {
|
||||
"success": True,
|
||||
"replacements": count,
|
||||
"document": self._maybe_return_doc(document_id, return_content),
|
||||
}
|
||||
|
||||
def apply_paragraph_format(
|
||||
self,
|
||||
document_id: str,
|
||||
contains: Optional[str],
|
||||
format: Dict[str, Any],
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
updated = 0
|
||||
for para in doc.paragraphs:
|
||||
if contains and (contains not in para.text):
|
||||
continue
|
||||
for run in para.runs or []:
|
||||
if "font_family" in format:
|
||||
run.font.name = format["font_family"]
|
||||
if "font_size" in format:
|
||||
run.font.size = Pt(int(format["font_size"]))
|
||||
if "bold" in format:
|
||||
run.bold = bool(format["bold"])
|
||||
if "italic" in format:
|
||||
run.italic = bool(format["italic"])
|
||||
if "underline" in format:
|
||||
run.underline = bool(format["underline"])
|
||||
if "color" in format:
|
||||
try:
|
||||
run.font.color.rgb = RGBColor.from_string(format["color"])
|
||||
except Exception:
|
||||
pass
|
||||
updated += 1
|
||||
return {
|
||||
"success": True,
|
||||
"paragraphs_updated": updated,
|
||||
"document": self._maybe_return_doc(document_id, return_content),
|
||||
}
|
||||
|
||||
def extract_text(self, document_id: str) -> str:
|
||||
doc = self.get_doc(document_id)
|
||||
return "\n".join(p.text for p in doc.paragraphs)
|
||||
|
||||
def get_tables(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
doc = self.get_doc(document_id)
|
||||
out = []
|
||||
for idx, table in enumerate(doc.tables):
|
||||
rows_data = []
|
||||
for row in table.rows:
|
||||
cells = [cell.text for cell in row.cells]
|
||||
rows_data.append(cells)
|
||||
out.append({
|
||||
"index": idx,
|
||||
"rows": len(table.rows),
|
||||
"cols": len(table.columns),
|
||||
"data": rows_data,
|
||||
})
|
||||
return out
|
||||
|
||||
def list_images(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def list_hyperlinks(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
doc = self.get_doc(document_id)
|
||||
links = []
|
||||
for p in doc.paragraphs:
|
||||
for m in re.finditer(r"\((https?://\S+)\)", p.text):
|
||||
links.append({"text": p.text.strip(), "url": m.group(1)})
|
||||
return links
|
||||
|
||||
def get_fields_summary(self, document_id: str) -> Dict[str, Any]:
|
||||
return {"note": "Fields summary not fully implemented in Python version"}
|
||||
|
||||
def strip_personal_info(self, document_id: str) -> None:
|
||||
doc = self.get_doc(document_id)
|
||||
core = doc.core_properties
|
||||
core.author = ""
|
||||
core.last_modified_by = ""
|
||||
core.revision_number = 1
|
||||
|
||||
def get_metadata(self, document_id: str) -> DocumentMetadata:
|
||||
info = self.documents[document_id]
|
||||
path = info["path"]
|
||||
size = os.path.getsize(path)
|
||||
doc = info["doc"]
|
||||
pages = max(1, len(doc.paragraphs) // 40)
|
||||
return DocumentMetadata(
|
||||
document_id=document_id,
|
||||
path=path,
|
||||
name=info["name"],
|
||||
size=size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
def save_document(
|
||||
self,
|
||||
document_id: str,
|
||||
output_path: str,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
info = self.documents[document_id]
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
info["doc"].save(output_path)
|
||||
return file_to_result(output_path, return_content=return_content)
|
||||
|
||||
# ---- conversion (best-effort, external tools optional) ----
|
||||
|
||||
def convert_to_pdf(
|
||||
self,
|
||||
document_id: str,
|
||||
output_path: str,
|
||||
prefer_external: bool = False,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
meta = self.get_metadata(document_id)
|
||||
# If external tools are allowed, you can call LibreOffice here;
|
||||
# for now, we indicate requirement.
|
||||
if prefer_external:
|
||||
raise NotImplementedError(
|
||||
"External PDF conversion not yet wired; "
|
||||
"configure LibreOffice/unoconv integration."
|
||||
)
|
||||
raise NotImplementedError(
|
||||
"PDF conversion not yet implemented in pure Python version."
|
||||
)
|
||||
|
||||
def export_pdf_with_field_refresh(
|
||||
self,
|
||||
document_id: str,
|
||||
output_path: str,
|
||||
prefer_external: bool = True,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
self.embed_page_number_fields(document_id)
|
||||
return self.convert_to_pdf(document_id, output_path, prefer_external, return_content=return_content)
|
||||
|
||||
def convert_to_images(
|
||||
self,
|
||||
document_id: str,
|
||||
output_dir: str,
|
||||
format: str = "png",
|
||||
dpi: int = 150,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
raise NotImplementedError(
|
||||
"Image conversion not yet implemented in pure Python version."
|
||||
)
|
||||
|
||||
def convert_to_images_with_preference(
|
||||
self,
|
||||
document_id: str,
|
||||
output_dir: str,
|
||||
format: str = "png",
|
||||
dpi: int = 150,
|
||||
prefer_external: bool = True,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
return self.convert_to_images(document_id, output_dir, format, dpi, return_content=return_content)
|
||||
|
||||
# ---- advanced docx operations ----
|
||||
|
||||
def merge_documents(
|
||||
self,
|
||||
document_ids: List[str],
|
||||
output_path: str,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
merged = Document()
|
||||
for did in document_ids:
|
||||
doc = self.get_doc(did)
|
||||
for elem in doc.element.body:
|
||||
merged.element.body.append(elem)
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
merged.save(output_path)
|
||||
return file_to_result(output_path, return_content=return_content)
|
||||
|
||||
def split_document(
|
||||
self,
|
||||
document_id: str,
|
||||
output_dir: str,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
# Very naive: split by page breaks.
|
||||
doc = self.get_doc(document_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
parts: List[Document] = []
|
||||
current = Document()
|
||||
for elem in doc.element.body:
|
||||
tag = elem.tag
|
||||
if "lastRenderedPageBreak" in tag or "pageBreakBefore" in tag:
|
||||
parts.append(current)
|
||||
current = Document()
|
||||
else:
|
||||
current.element.body.append(elem)
|
||||
if len(current.element.body) > 0:
|
||||
parts.append(current)
|
||||
|
||||
results = []
|
||||
for i, pdoc in enumerate(parts):
|
||||
path = os.path.join(output_dir, f"part_{i}.docx")
|
||||
pdoc.save(path)
|
||||
results.append(file_to_result(path, return_content=return_content))
|
||||
return {"parts": results}
|
||||
|
||||
def get_document_structure(self, document_id: str) -> Dict[str, Any]:
|
||||
doc = self.get_doc(document_id)
|
||||
headings = []
|
||||
for p in doc.paragraphs:
|
||||
if p.style.name.startswith("Heading"):
|
||||
headings.append({
|
||||
"text": p.text,
|
||||
"style": p.style.name,
|
||||
})
|
||||
return {
|
||||
"headings": headings,
|
||||
"paragraph_count": len(doc.paragraphs),
|
||||
"table_count": len(doc.tables),
|
||||
}
|
||||
|
||||
def get_outline(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
return self.get_document_structure(document_id).get("headings", [])
|
||||
|
||||
def get_ranges(self, document_id: str, selector: str) -> List[Dict[str, Any]]:
|
||||
# Minimal: "heading:'Text'" or "paragraph[i]"
|
||||
doc = self.get_doc(document_id)
|
||||
ranges = []
|
||||
if selector.startswith("heading:"):
|
||||
target = selector[len("heading:"):].strip().strip("'\"")
|
||||
for i, p in enumerate(doc.paragraphs):
|
||||
if p.style.name.startswith("Heading") and target.lower() in p.text.lower():
|
||||
ranges.append({"type": "paragraph", "index": i})
|
||||
elif selector.startswith("paragraph["):
|
||||
m = re.match(r"paragraph\[(\d+)\]", selector)
|
||||
if m:
|
||||
idx = int(m.group(1))
|
||||
ranges.append({"type": "paragraph", "index": idx})
|
||||
return ranges
|
||||
|
||||
def replace_range_text(
|
||||
self,
|
||||
document_id: str,
|
||||
range_id: Dict[str, Any],
|
||||
text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
if range_id.get("type") == "paragraph":
|
||||
idx = range_id.get("index")
|
||||
if 0 <= idx < len(doc.paragraphs):
|
||||
doc.paragraphs[idx].text = text
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def set_table_cell_text(
|
||||
self,
|
||||
document_id: str,
|
||||
table_index: int,
|
||||
row: int,
|
||||
col: int,
|
||||
text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
table = doc.tables[table_index]
|
||||
table.cell(row, col).text = text
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def get_document_properties(self, document_id: str) -> Dict[str, Any]:
|
||||
doc = self.get_doc(document_id)
|
||||
core = doc.core_properties
|
||||
return {
|
||||
"title": core.title,
|
||||
"subject": core.subject,
|
||||
"author": core.author,
|
||||
"last_modified_by": core.last_modified_by,
|
||||
"created": str(core.created),
|
||||
"modified": str(core.modified),
|
||||
}
|
||||
|
||||
def set_document_properties(
|
||||
self,
|
||||
document_id: str,
|
||||
title: Optional[str],
|
||||
subject: Optional[str],
|
||||
author: Optional[str],
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
core = doc.core_properties
|
||||
if title is not None:
|
||||
core.title = title
|
||||
if subject is not None:
|
||||
core.subject = subject
|
||||
if author is not None:
|
||||
core.author = author
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
|
||||
def insert_after_heading(
|
||||
self,
|
||||
document_id: str,
|
||||
heading_text: str,
|
||||
text: str,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
doc = self.get_doc(document_id)
|
||||
for p in doc.paragraphs:
|
||||
if p.style.name.startswith("Heading") and heading_text.lower() in p.text.lower():
|
||||
doc.add_paragraph(text)
|
||||
return self._maybe_return_doc(document_id, return_content)
|
||||
return {"success": False, "reason": "Heading not found"}
|
||||
|
||||
def sanitize_external_links(self, document_id: str) -> None:
|
||||
# naive: remove URLs from text
|
||||
doc = self.get_doc(document_id)
|
||||
for p in doc.paragraphs:
|
||||
p.text = re.sub(r"(https?://\S+)", "", p.text)
|
||||
|
||||
def redact_text(
|
||||
self,
|
||||
document_id: str,
|
||||
pattern: str,
|
||||
use_regex: bool = False,
|
||||
whole_word: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
return_content: bool = False,
|
||||
) -> Any:
|
||||
result = self.find_and_replace_advanced(
|
||||
document_id,
|
||||
pattern=pattern,
|
||||
replacement="█",
|
||||
case_sensitive=case_sensitive,
|
||||
whole_word=whole_word,
|
||||
use_regex=use_regex,
|
||||
return_content=return_content,
|
||||
)
|
||||
return result
|
||||
|
||||
def analyze_formatting(self, document_id: str) -> Dict[str, Any]:
|
||||
doc = self.get_doc(document_id)
|
||||
styles = set()
|
||||
fonts = set()
|
||||
for p in doc.paragraphs:
|
||||
styles.add(p.style.name)
|
||||
for run in p.runs or []:
|
||||
if run.font.name:
|
||||
fonts.add(run.font.name)
|
||||
return {
|
||||
"styles_used": list(styles),
|
||||
"fonts_detected": list(fonts),
|
||||
"has_tables": len(doc.tables) > 0,
|
||||
"has_images": False,
|
||||
"has_hyperlinks": any(
|
||||
"http" in p.text.lower() for p in doc.paragraphs
|
||||
),
|
||||
"page_count": max(1, len(doc.paragraphs) // 40),
|
||||
"section_count": len(doc.sections),
|
||||
}
|
||||
|
||||
def get_word_count(self, document_id: str) -> Dict[str, Any]:
|
||||
text = self.extract_text(document_id)
|
||||
words = text.split()
|
||||
chars = len(text)
|
||||
chars_no_spaces = len(text.replace(" ", ""))
|
||||
paragraphs = len([l for l in text.splitlines() if l.strip()])
|
||||
sentences = len(re.findall(r"[.!?]+", text))
|
||||
return {
|
||||
"words": len(words),
|
||||
"characters": chars,
|
||||
"characters_no_spaces": chars_no_spaces,
|
||||
"paragraphs": paragraphs,
|
||||
"sentences": sentences,
|
||||
"pages": max(1, len(words) // 250),
|
||||
"reading_time_minutes": max(1, len(words) // 200),
|
||||
}
|
||||
|
||||
def search_text(
|
||||
self,
|
||||
document_id: str,
|
||||
search_term: str,
|
||||
case_sensitive: bool = False,
|
||||
whole_word: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
text = self.extract_text(document_id)
|
||||
if not case_sensitive:
|
||||
text_lower = text.lower()
|
||||
term_lower = search_term.lower()
|
||||
else:
|
||||
text_lower = text
|
||||
term_lower = search_term
|
||||
|
||||
if whole_word:
|
||||
pattern = r"\b" + re_lib.escape(term_lower) + r"\b"
|
||||
else:
|
||||
pattern = re_lib.escape(term_lower)
|
||||
|
||||
matches = []
|
||||
for m in re_lib.finditer(pattern, text_lower):
|
||||
start = max(0, m.start() - 50)
|
||||
end = min(len(text), m.end() + 50)
|
||||
line = text[: m.start()].count("\n") + 1
|
||||
matches.append({
|
||||
"position": m.start(),
|
||||
"context": text[start:end],
|
||||
"line": line,
|
||||
})
|
||||
return {
|
||||
"matches": matches,
|
||||
"total_matches": len(matches),
|
||||
}
|
||||
|
||||
def export_to_markdown(
|
||||
self,
|
||||
document_id: str,
|
||||
output_path: str,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
text = self.extract_text(document_id)
|
||||
md_lines = []
|
||||
for line in text.splitlines():
|
||||
t = line.strip()
|
||||
if not t:
|
||||
md_lines.append("")
|
||||
continue
|
||||
if len(t) < 100 and any(c.isupper() for c in t):
|
||||
if all(c.isupper() or c.isspace() for c in t):
|
||||
md_lines.append(f"# {t}")
|
||||
else:
|
||||
md_lines.append(f"## {t}")
|
||||
else:
|
||||
md_lines.append(t)
|
||||
md = "\n\n".join(md_lines)
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(md)
|
||||
return file_to_result(output_path, return_content=return_content)
|
||||
|
||||
def export_to_html(
|
||||
self,
|
||||
document_id: str,
|
||||
output_path: str,
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
text = self.extract_text(document_id)
|
||||
html_parts = ['<html><head><meta charset="utf-8"></head><body>\n']
|
||||
for line in text.splitlines():
|
||||
t = line.strip()
|
||||
if not t:
|
||||
continue
|
||||
if len(t) < 100 and any(c.isupper() for c in t):
|
||||
if all(c.isupper() or c.isspace() for c in t):
|
||||
html_parts.append(f"<h1>{t}</h1>")
|
||||
else:
|
||||
html_parts.append(f"<h2>{t}</h2>")
|
||||
elif t.startswith("- ") or t.startswith("* "):
|
||||
html_parts.append(f"<ul><li>{t[2:]}</li></ul>")
|
||||
else:
|
||||
html_parts.append(f"<p>{t}</p>")
|
||||
html_parts.append("</body></html>\n")
|
||||
html = "\n".join(html_parts)
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
return file_to_result(output_path, return_content=return_content)
|
||||
|
||||
# ---- security and storage info ----
|
||||
|
||||
def get_security_info(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"readonly_mode": self.security_config.readonly_mode,
|
||||
"sandbox_mode": self.security_config.sandbox_mode,
|
||||
"allow_external_tools": self.security_config.allow_external_tools,
|
||||
"allow_network": self.security_config.allow_network,
|
||||
"max_document_size": self.security_config.max_document_size,
|
||||
"max_open_documents": self.security_config.max_open_documents,
|
||||
}
|
||||
|
||||
def get_storage_info(self) -> Dict[str, Any]:
|
||||
total = 0
|
||||
for info in self.documents.values():
|
||||
try:
|
||||
total += os.path.getsize(info["path"])
|
||||
except OSError:
|
||||
pass
|
||||
return {
|
||||
"temp_base": self._temp_base,
|
||||
"open_documents": len(self.documents),
|
||||
"total_size_bytes": total,
|
||||
}
|
||||
|
||||
# ---- templates ----
|
||||
|
||||
def open_template(self, name: str, templates_dir: str) -> str:
|
||||
path = os.path.join(templates_dir, name)
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError(f"Template not found: {name}")
|
||||
return self.open_document(path)
|
||||
|
||||
def generate_from_template(
|
||||
self,
|
||||
template_name: str,
|
||||
output_path: str,
|
||||
fields: Dict[str, str],
|
||||
return_content: bool = True,
|
||||
) -> Any:
|
||||
template_path = os.path.join(self.templates_dir, template_name)
|
||||
if not os.path.isfile(template_path):
|
||||
raise ValueError(f"Template not found: {template_name}")
|
||||
doc_id = self.open_document(template_path)
|
||||
for key, value in fields.items():
|
||||
placeholder = "{{" + key + "}}"
|
||||
self.find_and_replace_advanced(
|
||||
doc_id,
|
||||
pattern=placeholder,
|
||||
replacement=str(value),
|
||||
case_sensitive=False,
|
||||
whole_word=True,
|
||||
use_regex=False,
|
||||
return_content=False,
|
||||
)
|
||||
self.save_document(doc_id, output_path, return_content=False)
|
||||
self.close_document(doc_id)
|
||||
return file_to_result(output_path, return_content=return_content)
|
||||
|
||||
# ---- internal helper ----
|
||||
|
||||
def _maybe_return_doc(
|
||||
self,
|
||||
document_id: str,
|
||||
return_content: bool,
|
||||
) -> Any:
|
||||
"""
|
||||
If return_content is True, save the current document in-memory state
|
||||
to its path and return base64 content.
|
||||
"""
|
||||
if not return_content:
|
||||
return {"success": True, "document_id": document_id}
|
||||
|
||||
info = self.documents[document_id]
|
||||
info["doc"].save(info["path"])
|
||||
return file_to_result(info["path"], return_content=True)
|
||||
-1096
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
// Conditionally embed fonts if they exist
|
||||
// If fonts don't exist, we'll use empty placeholders and rely on PDF built-in fonts
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub static LIBERATION_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub static LIBERATION_SANS_REGULAR: &[u8] = &[];
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub static LIBERATION_SANS_BOLD: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Bold.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub static LIBERATION_SANS_BOLD: &[u8] = &[];
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub static LIBERATION_SANS_ITALIC: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Italic.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub static LIBERATION_SANS_ITALIC: &[u8] = &[];
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub static LIBERATION_MONO_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub static LIBERATION_MONO_REGULAR: &[u8] = &[];
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub const EMBEDDED_FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub const EMBEDDED_FONT_REGULAR: &[u8] = &[];
|
||||
|
||||
#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))]
|
||||
pub const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../assets/fonts/NotoSans-Bold.ttf");
|
||||
#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))]
|
||||
pub const EMBEDDED_FONT_BOLD: &[u8] = &[];
|
||||
|
||||
pub struct EmbeddedFonts {
|
||||
pub regular: &'static [u8],
|
||||
pub bold: &'static [u8],
|
||||
pub italic: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
pub static FONTS: Lazy<EmbeddedFonts> = Lazy::new(|| {
|
||||
EmbeddedFonts {
|
||||
regular: LIBERATION_SANS_REGULAR,
|
||||
bold: LIBERATION_SANS_BOLD,
|
||||
italic: LIBERATION_SANS_ITALIC,
|
||||
mono: LIBERATION_MONO_REGULAR,
|
||||
}
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
const FONTS_DIR: &str = "assets/fonts";
|
||||
|
||||
// Pin sources and expected checksums
|
||||
const LIBERATION_VERSION: &str = "2.1.5";
|
||||
const LIBERATION_TAR_URL: &str = "https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-2.1.5.tar.gz";
|
||||
const NOTO_BASE_URL: &str = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans";
|
||||
|
||||
const FONT_FILES: &[(&str, Option<&str>)] = &[
|
||||
("LiberationSans-Regular.ttf", Some("76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8")),
|
||||
("LiberationSans-Bold.ttf", Some("788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173")),
|
||||
("LiberationSans-Italic.ttf", Some("e5bae5c4cde31f22142753855f4f8fb86da6ff39955ed3c0a11248b0d16948b0")),
|
||||
("LiberationMono-Regular.ttf", Some("f2b83c763e8afd21709333370bed4774337fae82267937e2b5aea7e2fbd922c1")),
|
||||
("NotoSans-Regular.ttf", Some("b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5")),
|
||||
("NotoSans-Bold.ttf", Some("c976e4b1b99edc88775377fcc21692ca4bfa46b6d6ca6522bfda505b28ff9d6a")),
|
||||
];
|
||||
|
||||
pub fn download_fonts_blocking() -> Result<()> {
|
||||
fs::create_dir_all(FONTS_DIR).context("create fonts dir")?;
|
||||
|
||||
// Download Liberation tarball
|
||||
let tar_bytes = download_bytes(LIBERATION_TAR_URL)?;
|
||||
extract_liberation_from_tar(&tar_bytes, Path::new(FONTS_DIR))?;
|
||||
|
||||
// Download Noto fonts
|
||||
for name in ["NotoSans-Regular.ttf", "NotoSans-Bold.ttf"] {
|
||||
let url = format!("{}/{}", NOTO_BASE_URL, name);
|
||||
let bytes = download_bytes(&url)?;
|
||||
let out = Path::new(FONTS_DIR).join(name);
|
||||
fs::write(&out, bytes).context("write noto font")?;
|
||||
// verify immediate
|
||||
verify_single(&out, expected_for(name))?;
|
||||
}
|
||||
// Verify all fonts after extraction
|
||||
verify_fonts_blocking()
|
||||
}
|
||||
|
||||
pub fn verify_fonts_blocking() -> Result<()> {
|
||||
for (name, expected_opt) in FONT_FILES {
|
||||
let path = Path::new(FONTS_DIR).join(name);
|
||||
if !path.exists() {
|
||||
anyhow::bail!("missing font: {}", name);
|
||||
}
|
||||
let actual = sha256_file(&path)?;
|
||||
if let Some(expected) = expected_opt {
|
||||
if !actual.eq_ignore_ascii_case(expected) {
|
||||
anyhow::bail!("checksum mismatch for {}: {} != {}", name, actual, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_bytes(url: &str) -> Result<Vec<u8>> {
|
||||
let mut res = ureq::get(url).call().context("request failed")?;
|
||||
let mut buf = Vec::new();
|
||||
res.into_reader().read_to_end(&mut buf).context("read body")?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn extract_liberation_from_tar(tar_gz: &[u8], out_dir: &Path) -> Result<()> {
|
||||
let gz = flate2::read::GzDecoder::new(tar_gz);
|
||||
let mut archive = tar::Archive::new(gz);
|
||||
|
||||
for entry in archive.entries().context("iter entries")? {
|
||||
let mut entry = entry.context("entry")?;
|
||||
// Extract filename into an owned String to avoid borrowing `entry`
|
||||
let filename_owned: Option<String> = {
|
||||
let path_buf = entry.path().context("entry path")?;
|
||||
path_buf
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
};
|
||||
let Some(filename) = filename_owned.as_deref() else { continue };
|
||||
match filename {
|
||||
"LiberationSans-Regular.ttf" |
|
||||
"LiberationSans-Bold.ttf" |
|
||||
"LiberationSans-Italic.ttf" |
|
||||
"LiberationMono-Regular.ttf" => {
|
||||
let dest = out_dir.join(filename);
|
||||
let context_msg = format!("unpack {}", filename);
|
||||
entry.unpack(&dest).context(context_msg)?;
|
||||
// verify immediate
|
||||
verify_single(&dest, expected_for(filename))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_for(name: &str) -> Option<&'static str> {
|
||||
FONT_FILES.iter().find(|(n, _)| *n == name).and_then(|(_, s)| *s)
|
||||
}
|
||||
|
||||
fn verify_single(path: &Path, expected: Option<&str>) -> Result<()> {
|
||||
if let Some(exp) = expected {
|
||||
let actual = sha256_file(path)?;
|
||||
if !actual.eq_ignore_ascii_case(exp) {
|
||||
anyhow::bail!(
|
||||
"checksum mismatch for {}: {} != {}",
|
||||
path.display(),
|
||||
actual,
|
||||
exp
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String> {
|
||||
let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 { break; }
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
pub mod security;
|
||||
pub mod fonts_cli;
|
||||
|
||||
pub use security::{Args, SecurityConfig, SecurityMiddleware, SecurityError};
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "runtime-server")]
|
||||
use mcp_server::Server;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
use clap::Parser;
|
||||
|
||||
#[cfg(feature = "runtime-server")]
|
||||
mod docx_tools;
|
||||
#[cfg(feature = "runtime-server")]
|
||||
mod docx_handler;
|
||||
#[cfg(feature = "runtime-server")]
|
||||
mod converter;
|
||||
#[cfg(feature = "runtime-server")]
|
||||
mod pure_converter;
|
||||
#[cfg(feature = "runtime-server")]
|
||||
mod advanced_docx;
|
||||
mod security;
|
||||
|
||||
#[cfg(feature = "embedded-fonts")]
|
||||
mod fonts;
|
||||
|
||||
#[cfg(feature = "runtime-server")]
|
||||
use docx_tools::DocxToolsProvider;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
// Parse command line arguments (which also includes environment variables)
|
||||
let args = security::Args::parse();
|
||||
|
||||
// Handle top-level subcommands that should run and exit
|
||||
if let Some(cmd) = &args.command {
|
||||
match cmd {
|
||||
security::CliCommand::Fonts { action } => {
|
||||
match action {
|
||||
security::FontsAction::Download => {
|
||||
docx_mcp::fonts_cli::download_fonts_blocking()?;
|
||||
info!("Fonts downloaded successfully");
|
||||
return Ok(());
|
||||
}
|
||||
security::FontsAction::Verify => {
|
||||
docx_mcp::fonts_cli::verify_fonts_blocking()?;
|
||||
info!("Fonts verified successfully");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "runtime-server")]
|
||||
{
|
||||
let security_config = security::SecurityConfig::from_args(args);
|
||||
info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary());
|
||||
|
||||
// TODO: Integrate with mcp-server Router here. For now, just exit successfully.
|
||||
info!("Server integration pending refactor; exiting.");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "runtime-server"))]
|
||||
{
|
||||
// No runtime server compiled in; if no subcommand was used, exit with guidance
|
||||
eprintln!("Runtime server disabled. Rebuild with --features runtime-server to run the MCP server.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
use anyhow::{Context, Result};
|
||||
use ::image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||
use printpdf::*;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tempfile::NamedTempFile;
|
||||
use tracing::{debug, info, warn};
|
||||
use roxmltree;
|
||||
use zip::ZipArchive;
|
||||
use rusttype::{Font, Scale};
|
||||
use lopdf::{self, dictionary, Object};
|
||||
|
||||
pub struct PureRustConverter;
|
||||
|
||||
impl PureRustConverter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Extract text from DOCX using pure Rust XML parsing
|
||||
pub fn extract_text_from_docx(&self, docx_path: &Path) -> Result<String> {
|
||||
let file = File::open(docx_path)?;
|
||||
let mut archive = ZipArchive::new(file)?;
|
||||
|
||||
// Find the main document XML
|
||||
let mut document_xml = String::new();
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
if name == "word/document.xml" {
|
||||
file.read_to_string(&mut document_xml)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if document_xml.is_empty() {
|
||||
anyhow::bail!("No document.xml found in DOCX file");
|
||||
}
|
||||
|
||||
// Parse XML and extract text
|
||||
let doc = roxmltree::Document::parse(&document_xml)?;
|
||||
let mut text = String::new();
|
||||
|
||||
// Extract text from all w:t elements
|
||||
for node in doc.descendants() {
|
||||
if node.tag_name().name() == "t" {
|
||||
if let Some(node_text) = node.text() {
|
||||
text.push_str(node_text);
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
// Handle line breaks
|
||||
if node.tag_name().name() == "br" || node.tag_name().name() == "p" {
|
||||
text.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
Ok(text.trim().to_string())
|
||||
}
|
||||
|
||||
/// Convert DOCX to PDF using pure Rust (no external dependencies)
|
||||
pub fn docx_to_pdf_pure(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> {
|
||||
// Extract text from DOCX
|
||||
let text = self.extract_text_from_docx(docx_path)
|
||||
.with_context(|| format!("Failed to extract text from {:?}", docx_path))?;
|
||||
|
||||
// Create PDF with extracted text
|
||||
self.create_pdf_from_text(&text, pdf_path)?;
|
||||
|
||||
info!("Successfully converted DOCX to PDF using pure Rust");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a PDF from text content
|
||||
pub fn create_pdf_from_text(&self, text: &str, pdf_path: &Path) -> Result<()> {
|
||||
let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1");
|
||||
let current_layer = doc.get_page(page1).get_layer(layer1);
|
||||
|
||||
// Use embedded font or built-in font
|
||||
let font = doc.add_builtin_font(BuiltinFont::Helvetica)?;
|
||||
|
||||
// Configure text layout
|
||||
let font_size = 11.0;
|
||||
let line_height = Mm(5.0);
|
||||
let margin_left = Mm(20.0);
|
||||
let margin_top = Mm(280.0);
|
||||
let margin_bottom = Mm(20.0);
|
||||
let page_width = Mm(210.0);
|
||||
let page_height = Mm(297.0);
|
||||
let text_width = page_width - (margin_left * 2.0);
|
||||
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut current_page = page1;
|
||||
let mut current_layer = layer1;
|
||||
let mut y_position = margin_top;
|
||||
|
||||
for line in lines {
|
||||
// Check if we need a new page
|
||||
if y_position < margin_bottom {
|
||||
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
|
||||
current_page = new_page;
|
||||
current_layer = new_layer;
|
||||
y_position = margin_top;
|
||||
}
|
||||
|
||||
// Word wrap if line is too long
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
let mut current_line = String::new();
|
||||
let max_chars_per_line = 80; // Approximate
|
||||
|
||||
for word in words {
|
||||
if current_line.len() + word.len() + 1 > max_chars_per_line {
|
||||
// Write current line
|
||||
if !current_line.is_empty() {
|
||||
doc.get_page(current_page)
|
||||
.get_layer(current_layer)
|
||||
.use_text(¤t_line, font_size, margin_left, y_position, &font);
|
||||
y_position -= line_height;
|
||||
current_line.clear();
|
||||
|
||||
// Check for new page
|
||||
if y_position < margin_bottom {
|
||||
let (new_page, new_layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer");
|
||||
current_page = new_page;
|
||||
current_layer = new_layer;
|
||||
y_position = margin_top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
current_line.push(' ');
|
||||
}
|
||||
current_line.push_str(word);
|
||||
}
|
||||
|
||||
// Write remaining text in line
|
||||
if !current_line.is_empty() {
|
||||
doc.get_page(current_page)
|
||||
.get_layer(current_layer)
|
||||
.use_text(¤t_line, font_size, margin_left, y_position, &font);
|
||||
y_position -= line_height;
|
||||
}
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
doc.save(&mut BufWriter::new(File::create(pdf_path)?))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert PDF to images using pure Rust
|
||||
pub fn pdf_to_images_pure(
|
||||
&self,
|
||||
pdf_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
// Parse PDF
|
||||
let doc = lopdf::Document::load(pdf_path)?;
|
||||
let pages = doc.get_pages();
|
||||
|
||||
fs::create_dir_all(output_dir)?;
|
||||
let mut output_paths = Vec::new();
|
||||
|
||||
// For each page, render to image
|
||||
for (page_num, (_page_num, _page_id)) in pages.iter().enumerate() {
|
||||
// Create a blank image for the page
|
||||
// In a real implementation, you would render the PDF content
|
||||
let img = self.render_pdf_page_to_image(&doc, page_num)?;
|
||||
|
||||
// Save image
|
||||
let extension = match format {
|
||||
ImageFormat::Png => "png",
|
||||
ImageFormat::Jpeg => "jpg",
|
||||
_ => "png",
|
||||
};
|
||||
|
||||
let output_path = output_dir.join(format!("page_{:03}.{}", page_num + 1, extension));
|
||||
img.save_with_format(&output_path, format)?;
|
||||
output_paths.push(output_path);
|
||||
}
|
||||
|
||||
Ok(output_paths)
|
||||
}
|
||||
|
||||
/// Render a PDF page to image (simplified implementation)
|
||||
fn render_pdf_page_to_image(&self, _doc: &lopdf::Document, _page_num: usize) -> Result<DynamicImage> {
|
||||
// This is a simplified implementation
|
||||
// A full implementation would parse PDF content and render it
|
||||
|
||||
// Create a white image as placeholder
|
||||
let width = 1240; // A4 at 150 DPI
|
||||
let height = 1754; // A4 at 150 DPI
|
||||
|
||||
let mut img = RgbaImage::new(width, height);
|
||||
|
||||
// Fill with white background
|
||||
for pixel in img.pixels_mut() {
|
||||
*pixel = Rgba([255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
// Add a simple text indicator
|
||||
// In production, you would properly render PDF content
|
||||
|
||||
Ok(DynamicImage::ImageRgba8(img))
|
||||
}
|
||||
|
||||
/// Convert DOCX to images using pure Rust
|
||||
pub fn docx_to_images_pure(
|
||||
&self,
|
||||
docx_path: &Path,
|
||||
output_dir: &Path,
|
||||
format: ImageFormat,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
// First convert to PDF
|
||||
let temp_pdf = NamedTempFile::new()?.into_temp_path();
|
||||
self.docx_to_pdf_pure(docx_path, &temp_pdf)?;
|
||||
|
||||
// Then convert PDF to images
|
||||
self.pdf_to_images_pure(&temp_pdf, output_dir, format)
|
||||
}
|
||||
|
||||
/// Create a thumbnail from an image
|
||||
pub fn create_thumbnail(
|
||||
&self,
|
||||
image_path: &Path,
|
||||
output_path: &Path,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<()> {
|
||||
let img = ::image::open(image_path)
|
||||
.with_context(|| format!("Failed to open image {:?}", image_path))?;
|
||||
|
||||
let thumbnail = img.thumbnail(width, height);
|
||||
thumbnail.save(output_path)
|
||||
.with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?;
|
||||
|
||||
info!("Created thumbnail {}x{} at {:?}", width, height, output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Merge multiple PDFs using pure Rust
|
||||
pub fn merge_pdfs_pure(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> {
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
|
||||
// Create a new document for merging
|
||||
let mut merged_doc = Document::with_version("1.5");
|
||||
|
||||
// Track page tree
|
||||
let mut all_pages = Vec::new();
|
||||
|
||||
for pdf_path in pdf_paths {
|
||||
let doc = Document::load(pdf_path)?;
|
||||
|
||||
// Get pages from the document
|
||||
let pages = doc.get_pages();
|
||||
|
||||
for (_page_num, page_id) in pages.iter() {
|
||||
// Clone the page object
|
||||
if let Ok(page_obj) = doc.get_object(*page_id) {
|
||||
let new_id = merged_doc.new_object_id();
|
||||
merged_doc.objects.insert(new_id, page_obj.clone());
|
||||
all_pages.push(new_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the page tree for merged document
|
||||
let pages_id = merged_doc.new_object_id();
|
||||
let pages_dict = lopdf::dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => all_pages.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
|
||||
"Count" => all_pages.len() as i32,
|
||||
};
|
||||
merged_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
|
||||
|
||||
// Update catalog
|
||||
let catalog_id = merged_doc.new_object_id();
|
||||
let catalog = lopdf::dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
};
|
||||
merged_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
|
||||
merged_doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
// Save the merged PDF
|
||||
merged_doc.save(output_path)?;
|
||||
|
||||
info!("Successfully merged {} PDFs into {:?}", pdf_paths.len(), output_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split a PDF into individual pages using pure Rust
|
||||
pub fn split_pdf_pure(&self, pdf_path: &Path, output_dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
use lopdf::Document;
|
||||
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let doc = Document::load(pdf_path)?;
|
||||
let pages = doc.get_pages();
|
||||
let mut output_paths = Vec::new();
|
||||
|
||||
for (i, (_page_num, page_id)) in pages.iter().enumerate() {
|
||||
// Create a new document with just this page
|
||||
let mut single_page_doc = Document::with_version("1.5");
|
||||
|
||||
// Clone the page
|
||||
if let Ok(page_obj) = doc.get_object(*page_id) {
|
||||
let new_page_id = single_page_doc.new_object_id();
|
||||
single_page_doc.objects.insert(new_page_id, page_obj.clone());
|
||||
|
||||
// Create page tree
|
||||
let pages_id = single_page_doc.new_object_id();
|
||||
let pages_dict = lopdf::dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(new_page_id)],
|
||||
"Count" => 1,
|
||||
};
|
||||
single_page_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
|
||||
|
||||
// Create catalog
|
||||
let catalog_id = single_page_doc.new_object_id();
|
||||
let catalog = lopdf::dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
};
|
||||
single_page_doc.objects.insert(catalog_id, Object::Dictionary(catalog));
|
||||
single_page_doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
// Save the page
|
||||
let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1));
|
||||
single_page_doc.save(&output_path)?;
|
||||
output_paths.push(output_path);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Split PDF into {} pages", output_paths.len());
|
||||
Ok(output_paths)
|
||||
}
|
||||
|
||||
/// Parse and render markdown to PDF
|
||||
pub fn markdown_to_pdf(&self, markdown: &str, pdf_path: &Path) -> Result<()> {
|
||||
use pulldown_cmark::{Parser, Event, Tag, TagEnd};
|
||||
|
||||
let parser = Parser::new(markdown);
|
||||
let mut plain_text = String::new();
|
||||
let mut in_code_block = false;
|
||||
let mut list_depth = 0;
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Text(text) => {
|
||||
if in_code_block {
|
||||
plain_text.push_str(" ");
|
||||
} else if list_depth > 0 {
|
||||
plain_text.push_str(&" ".repeat(list_depth));
|
||||
}
|
||||
plain_text.push_str(&text);
|
||||
}
|
||||
Event::Start(tag) => {
|
||||
match tag {
|
||||
Tag::Heading { level, .. } => {
|
||||
plain_text.push('\n');
|
||||
plain_text.push_str(&"#".repeat(level as usize));
|
||||
plain_text.push(' ');
|
||||
}
|
||||
Tag::Paragraph => {
|
||||
if !plain_text.is_empty() {
|
||||
plain_text.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
Tag::List(_) => {
|
||||
list_depth += 1;
|
||||
plain_text.push('\n');
|
||||
}
|
||||
Tag::Item => {
|
||||
plain_text.push_str("• ");
|
||||
}
|
||||
Tag::CodeBlock(_) => {
|
||||
in_code_block = true;
|
||||
plain_text.push_str("\n\n");
|
||||
}
|
||||
Tag::Emphasis => plain_text.push('*'),
|
||||
Tag::Strong => plain_text.push_str("**"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(tag) => {
|
||||
match tag {
|
||||
TagEnd::Heading(_) => plain_text.push_str("\n\n"),
|
||||
TagEnd::Paragraph => plain_text.push('\n'),
|
||||
TagEnd::List(_) => {
|
||||
list_depth = list_depth.saturating_sub(1);
|
||||
plain_text.push('\n');
|
||||
}
|
||||
TagEnd::Item => plain_text.push('\n'),
|
||||
TagEnd::CodeBlock => {
|
||||
in_code_block = false;
|
||||
plain_text.push_str("\n\n");
|
||||
}
|
||||
TagEnd::Emphasis => plain_text.push('*'),
|
||||
TagEnd::Strong => plain_text.push_str("**"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Code(code) => {
|
||||
plain_text.push('`');
|
||||
plain_text.push_str(&code);
|
||||
plain_text.push('`');
|
||||
}
|
||||
Event::SoftBreak => plain_text.push(' '),
|
||||
Event::HardBreak => plain_text.push('\n'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.create_pdf_from_text(&plain_text, pdf_path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Set
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityConfig:
|
||||
readonly_mode: bool
|
||||
sandbox_mode: bool
|
||||
allow_external_tools: bool
|
||||
allow_network: bool
|
||||
max_document_size: int
|
||||
max_open_documents: int
|
||||
|
||||
def get_summary(self) -> str:
|
||||
parts = []
|
||||
if self.readonly_mode:
|
||||
parts.append("readonly")
|
||||
if self.sandbox_mode:
|
||||
parts.append("sandbox")
|
||||
if self.allow_external_tools:
|
||||
parts.append("external-tools")
|
||||
if self.allow_network:
|
||||
parts.append("network")
|
||||
return ", ".join(parts) or "default"
|
||||
|
||||
|
||||
# Tools allowed in readonly mode
|
||||
READONLY_COMMANDS: Set[str] = {
|
||||
"list_documents",
|
||||
"open_document",
|
||||
"extract_text",
|
||||
"get_metadata",
|
||||
"get_document_structure",
|
||||
"get_outline",
|
||||
"get_ranges",
|
||||
"get_tables",
|
||||
"list_images",
|
||||
"list_hyperlinks",
|
||||
"get_fields_summary",
|
||||
"get_document_properties",
|
||||
"get_word_count",
|
||||
"search_text",
|
||||
"analyze_formatting",
|
||||
"get_security_info",
|
||||
"get_storage_info",
|
||||
"list_templates",
|
||||
}
|
||||
|
||||
# Tools that modify documents
|
||||
WRITE_COMMANDS: Set[str] = {
|
||||
"create_document",
|
||||
"add_paragraph",
|
||||
"add_heading",
|
||||
"add_table",
|
||||
"add_section_break",
|
||||
"add_list",
|
||||
"add_list_item",
|
||||
"add_page_break",
|
||||
"insert_toc",
|
||||
"insert_bookmark_after_heading",
|
||||
"set_header",
|
||||
"set_footer",
|
||||
"set_page_numbering",
|
||||
"embed_page_number_fields",
|
||||
"add_image",
|
||||
"add_hyperlink",
|
||||
"find_and_replace",
|
||||
"find_and_replace_advanced",
|
||||
"apply_paragraph_format",
|
||||
"save_document",
|
||||
"close_document",
|
||||
"convert_to_pdf",
|
||||
"export_pdf_with_field_refresh",
|
||||
"convert_to_images",
|
||||
"convert_to_images_with_preference",
|
||||
"merge_documents",
|
||||
"split_document",
|
||||
"replace_range_text",
|
||||
"set_table_cell_text",
|
||||
"set_document_properties",
|
||||
"insert_after_heading",
|
||||
"sanitize_external_links",
|
||||
"redact_text",
|
||||
"strip_personal_info",
|
||||
"export_to_markdown",
|
||||
"export_to_html",
|
||||
"open_template",
|
||||
"generate_from_template",
|
||||
}
|
||||
|
||||
|
||||
def is_command_allowed(name: str, config: SecurityConfig) -> bool:
|
||||
if config.readonly_mode:
|
||||
return name in READONLY_COMMANDS
|
||||
return True
|
||||
-512
@@ -1,512 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use tracing::{debug, info, warn};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// Command line arguments for the DOCX MCP server
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "docx-mcp")]
|
||||
#[command(about = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation")]
|
||||
#[command(version)]
|
||||
pub struct Args {
|
||||
/// Enable readonly mode - only allow viewing operations
|
||||
#[arg(long, env = "DOCX_MCP_READONLY")]
|
||||
pub readonly: bool,
|
||||
|
||||
/// Comma-separated whitelist of allowed commands
|
||||
#[arg(long, env = "DOCX_MCP_WHITELIST", value_delimiter = ',')]
|
||||
pub whitelist: Option<Vec<String>>,
|
||||
|
||||
/// Comma-separated blacklist of forbidden commands
|
||||
#[arg(long, env = "DOCX_MCP_BLACKLIST", value_delimiter = ',')]
|
||||
pub blacklist: Option<Vec<String>>,
|
||||
|
||||
/// Enable sandbox mode - restrict file operations to temp directory only
|
||||
#[arg(long, env = "DOCX_MCP_SANDBOX")]
|
||||
pub sandbox: bool,
|
||||
|
||||
/// Disable external tools (LibreOffice, etc.)
|
||||
#[arg(long, env = "DOCX_MCP_NO_EXTERNAL_TOOLS")]
|
||||
pub no_external_tools: bool,
|
||||
|
||||
/// Disable network operations
|
||||
#[arg(long, env = "DOCX_MCP_NO_NETWORK")]
|
||||
pub no_network: bool,
|
||||
|
||||
/// Maximum document size in bytes
|
||||
#[arg(long, env = "DOCX_MCP_MAX_SIZE")]
|
||||
pub max_size: Option<usize>,
|
||||
|
||||
/// Maximum number of open documents
|
||||
#[arg(long, env = "DOCX_MCP_MAX_DOCS")]
|
||||
pub max_docs: Option<usize>,
|
||||
|
||||
/// Optional top-level subcommand (e.g., fonts download)
|
||||
#[command(subcommand)]
|
||||
pub command: Option<CliCommand>,
|
||||
}
|
||||
|
||||
/// Security configuration for the MCP server
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecurityConfig {
|
||||
/// If true, only allow read-only operations
|
||||
pub readonly_mode: bool,
|
||||
|
||||
/// Whitelist of allowed commands (if set, only these commands are allowed)
|
||||
pub command_whitelist: Option<HashSet<String>>,
|
||||
|
||||
/// Blacklist of forbidden commands (if set, these commands are blocked)
|
||||
pub command_blacklist: Option<HashSet<String>>,
|
||||
|
||||
/// Maximum document size in bytes (default: 100MB)
|
||||
pub max_document_size: usize,
|
||||
|
||||
/// Maximum number of open documents (default: 50)
|
||||
pub max_open_documents: usize,
|
||||
|
||||
/// Allow external tool usage (LibreOffice, etc.)
|
||||
pub allow_external_tools: bool,
|
||||
|
||||
/// Allow network operations (downloading templates, fonts, etc.)
|
||||
pub allow_network: bool,
|
||||
|
||||
/// Sandbox mode - restricts file operations to temp directory only
|
||||
pub sandbox_mode: bool,
|
||||
}
|
||||
|
||||
/// Top-level CLI subcommands
|
||||
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum CliCommand {
|
||||
/// Font utilities
|
||||
Fonts {
|
||||
#[command(subcommand)]
|
||||
action: FontsAction,
|
||||
},
|
||||
}
|
||||
|
||||
/// Font-related actions
|
||||
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum FontsAction {
|
||||
/// Download open-source fonts into assets/fonts
|
||||
Download,
|
||||
/// Verify checksums of fonts in assets/fonts
|
||||
Verify,
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
readonly_mode: false,
|
||||
command_whitelist: None,
|
||||
command_blacklist: None,
|
||||
max_document_size: 100 * 1024 * 1024, // 100MB
|
||||
max_open_documents: 50,
|
||||
allow_external_tools: true,
|
||||
allow_network: true,
|
||||
sandbox_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityConfig {
|
||||
/// Create configuration from command line arguments
|
||||
pub fn from_args(args: Args) -> Self {
|
||||
let mut config = Self::default();
|
||||
|
||||
// Apply command line arguments
|
||||
if args.readonly {
|
||||
config.readonly_mode = true;
|
||||
info!("Running in READONLY mode - only viewing operations allowed");
|
||||
}
|
||||
|
||||
if let Some(whitelist) = args.whitelist {
|
||||
let commands: HashSet<String> = whitelist.into_iter().collect();
|
||||
info!("Command whitelist enabled with {} commands", commands.len());
|
||||
config.command_whitelist = Some(commands);
|
||||
}
|
||||
|
||||
if let Some(blacklist) = args.blacklist {
|
||||
let commands: HashSet<String> = blacklist.into_iter().collect();
|
||||
info!("Command blacklist enabled with {} blocked commands", commands.len());
|
||||
config.command_blacklist = Some(commands);
|
||||
}
|
||||
|
||||
if args.sandbox {
|
||||
config.sandbox_mode = true;
|
||||
config.allow_external_tools = false;
|
||||
config.allow_network = false;
|
||||
info!("Running in SANDBOX mode - restricted file operations");
|
||||
}
|
||||
|
||||
if args.no_external_tools {
|
||||
config.allow_external_tools = false;
|
||||
info!("External tools disabled");
|
||||
}
|
||||
|
||||
if args.no_network {
|
||||
config.allow_network = false;
|
||||
info!("Network operations disabled");
|
||||
}
|
||||
|
||||
if let Some(size) = args.max_size {
|
||||
config.max_document_size = size;
|
||||
info!("Max document size set to {} bytes", size);
|
||||
}
|
||||
|
||||
if let Some(max) = args.max_docs {
|
||||
config.max_open_documents = max;
|
||||
info!("Max open documents set to {}", max);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables (deprecated, use from_args instead)
|
||||
pub fn from_env() -> Self {
|
||||
let mut config = Self::default();
|
||||
|
||||
// Check for readonly mode
|
||||
if env::var("DOCX_MCP_READONLY").unwrap_or_default() == "true" {
|
||||
config.readonly_mode = true;
|
||||
info!("Running in READONLY mode - only viewing operations allowed");
|
||||
}
|
||||
|
||||
// Check for command whitelist
|
||||
if let Ok(whitelist) = env::var("DOCX_MCP_WHITELIST") {
|
||||
let commands: HashSet<String> = whitelist
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
config.command_whitelist = Some(commands.clone());
|
||||
info!("Command whitelist enabled with {} commands", commands.len());
|
||||
}
|
||||
|
||||
// Check for command blacklist
|
||||
if let Ok(blacklist) = env::var("DOCX_MCP_BLACKLIST") {
|
||||
let commands: HashSet<String> = blacklist
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect();
|
||||
config.command_blacklist = Some(commands.clone());
|
||||
info!("Command blacklist enabled with {} blocked commands", commands.len());
|
||||
}
|
||||
|
||||
// Check for sandbox mode
|
||||
if env::var("DOCX_MCP_SANDBOX").unwrap_or_default() == "true" {
|
||||
config.sandbox_mode = true;
|
||||
config.allow_external_tools = false;
|
||||
config.allow_network = false;
|
||||
info!("Running in SANDBOX mode - restricted file operations");
|
||||
}
|
||||
|
||||
// Check for external tools permission
|
||||
if env::var("DOCX_MCP_NO_EXTERNAL_TOOLS").unwrap_or_default() == "true" {
|
||||
config.allow_external_tools = false;
|
||||
info!("External tools disabled");
|
||||
}
|
||||
|
||||
// Check for network permission
|
||||
if env::var("DOCX_MCP_NO_NETWORK").unwrap_or_default() == "true" {
|
||||
config.allow_network = false;
|
||||
info!("Network operations disabled");
|
||||
}
|
||||
|
||||
// Max document size
|
||||
if let Ok(size) = env::var("DOCX_MCP_MAX_SIZE") {
|
||||
if let Ok(bytes) = size.parse::<usize>() {
|
||||
config.max_document_size = bytes;
|
||||
info!("Max document size set to {} bytes", bytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Max open documents
|
||||
if let Ok(max) = env::var("DOCX_MCP_MAX_DOCS") {
|
||||
if let Ok(count) = max.parse::<usize>() {
|
||||
config.max_open_documents = count;
|
||||
info!("Max open documents set to {}", count);
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Check if a command is allowed based on security configuration
|
||||
pub fn is_command_allowed(&self, command: &str) -> bool {
|
||||
// First check if it's a readonly command
|
||||
let readonly_commands = Self::get_readonly_commands();
|
||||
let is_readonly_command = readonly_commands.contains(command);
|
||||
|
||||
// In readonly mode, only allow readonly commands
|
||||
if self.readonly_mode && !is_readonly_command {
|
||||
debug!("Command '{}' blocked: readonly mode", command);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check whitelist (if set, only whitelisted commands are allowed)
|
||||
if let Some(ref whitelist) = self.command_whitelist {
|
||||
if !whitelist.contains(command) {
|
||||
debug!("Command '{}' blocked: not in whitelist", command);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check blacklist (if set, blacklisted commands are blocked)
|
||||
if let Some(ref blacklist) = self.command_blacklist {
|
||||
if blacklist.contains(command) {
|
||||
debug!("Command '{}' blocked: in blacklist", command);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional checks for specific command categories
|
||||
if command.starts_with("convert_") && !self.allow_external_tools {
|
||||
debug!("Command '{}' blocked: external tools disabled", command);
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Get list of readonly commands
|
||||
pub fn get_readonly_commands() -> HashSet<&'static str> {
|
||||
let mut commands = HashSet::new();
|
||||
|
||||
// Document viewing commands
|
||||
commands.insert("open_document");
|
||||
commands.insert("extract_text");
|
||||
commands.insert("get_metadata");
|
||||
commands.insert("list_documents");
|
||||
commands.insert("get_document_info");
|
||||
commands.insert("read_paragraph");
|
||||
commands.insert("read_table");
|
||||
commands.insert("read_section");
|
||||
commands.insert("search_text");
|
||||
commands.insert("get_document_structure");
|
||||
commands.insert("get_styles");
|
||||
commands.insert("get_headers_footers");
|
||||
commands.insert("get_page_count");
|
||||
commands.insert("get_word_count");
|
||||
commands.insert("get_table_of_contents");
|
||||
commands.insert("list_bookmarks");
|
||||
commands.insert("list_hyperlinks");
|
||||
commands.insert("list_comments");
|
||||
commands.insert("list_footnotes");
|
||||
commands.insert("list_endnotes");
|
||||
commands.insert("get_document_properties");
|
||||
|
||||
// Analysis commands
|
||||
commands.insert("analyze_formatting");
|
||||
commands.insert("check_spelling");
|
||||
commands.insert("check_grammar");
|
||||
commands.insert("get_statistics");
|
||||
commands.insert("compare_documents");
|
||||
|
||||
// Export commands (readonly as they don't modify the original)
|
||||
commands.insert("export_to_json");
|
||||
commands.insert("export_to_markdown");
|
||||
commands.insert("export_to_html");
|
||||
commands.insert("create_preview");
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Get list of write commands (for documentation)
|
||||
pub fn get_write_commands() -> HashSet<&'static str> {
|
||||
let mut commands = HashSet::new();
|
||||
|
||||
// Document creation/modification
|
||||
commands.insert("create_document");
|
||||
commands.insert("save_document");
|
||||
commands.insert("close_document");
|
||||
|
||||
// Content addition
|
||||
commands.insert("add_paragraph");
|
||||
commands.insert("add_heading");
|
||||
commands.insert("add_table");
|
||||
commands.insert("add_list");
|
||||
commands.insert("add_page_break");
|
||||
commands.insert("add_section_break");
|
||||
commands.insert("add_image");
|
||||
commands.insert("add_chart");
|
||||
commands.insert("add_shape");
|
||||
commands.insert("add_hyperlink");
|
||||
commands.insert("add_bookmark");
|
||||
commands.insert("add_footnote");
|
||||
commands.insert("add_endnote");
|
||||
commands.insert("add_comment");
|
||||
commands.insert("add_watermark");
|
||||
|
||||
// Content modification
|
||||
commands.insert("edit_paragraph");
|
||||
commands.insert("delete_paragraph");
|
||||
commands.insert("find_and_replace");
|
||||
commands.insert("update_table");
|
||||
commands.insert("update_style");
|
||||
commands.insert("set_header");
|
||||
commands.insert("set_footer");
|
||||
commands.insert("set_margins");
|
||||
commands.insert("set_page_size");
|
||||
commands.insert("apply_template");
|
||||
commands.insert("apply_style");
|
||||
commands.insert("apply_theme");
|
||||
|
||||
// Document operations
|
||||
commands.insert("merge_documents");
|
||||
commands.insert("split_document");
|
||||
commands.insert("convert_to_pdf");
|
||||
commands.insert("convert_to_images");
|
||||
commands.insert("protect_document");
|
||||
commands.insert("unprotect_document");
|
||||
commands.insert("track_changes");
|
||||
commands.insert("accept_changes");
|
||||
commands.insert("reject_changes");
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
/// Check if a file path is allowed based on sandbox configuration
|
||||
pub fn is_path_allowed(&self, path: &std::path::Path) -> bool {
|
||||
if !self.sandbox_mode {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In sandbox mode, only allow operations in temp directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
if let Ok(canonical_path) = path.canonicalize() {
|
||||
if let Ok(canonical_temp) = temp_dir.canonicalize() {
|
||||
return canonical_path.starts_with(canonical_temp);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get a summary of current security settings
|
||||
pub fn get_summary(&self) -> String {
|
||||
let mut summary: Vec<String> = Vec::new();
|
||||
|
||||
if self.readonly_mode {
|
||||
summary.push("📖 READONLY MODE".to_string());
|
||||
}
|
||||
|
||||
if self.sandbox_mode {
|
||||
summary.push("🔒 SANDBOX MODE".to_string());
|
||||
}
|
||||
|
||||
if let Some(ref whitelist) = self.command_whitelist {
|
||||
summary.push(format!("✅ Whitelist: {} commands", whitelist.len()));
|
||||
}
|
||||
|
||||
if let Some(ref blacklist) = self.command_blacklist {
|
||||
summary.push(format!("🚫 Blacklist: {} commands", blacklist.len()));
|
||||
}
|
||||
|
||||
if !self.allow_external_tools {
|
||||
summary.push("🔧 No external tools".to_string());
|
||||
}
|
||||
|
||||
if !self.allow_network {
|
||||
summary.push("🌐 No network access".to_string());
|
||||
}
|
||||
|
||||
if summary.is_empty() {
|
||||
"Standard mode (all features enabled)".to_string()
|
||||
} else {
|
||||
summary.join(" | ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Security middleware to check commands before execution
|
||||
pub struct SecurityMiddleware {
|
||||
config: SecurityConfig,
|
||||
}
|
||||
|
||||
impl SecurityMiddleware {
|
||||
pub fn new(config: SecurityConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
/// Check if a command should be allowed to execute
|
||||
pub fn check_command(&self, command: &str, arguments: &serde_json::Value) -> Result<(), SecurityError> {
|
||||
// Check if command is allowed
|
||||
if !self.config.is_command_allowed(command) {
|
||||
return Err(SecurityError::CommandNotAllowed(command.to_string()));
|
||||
}
|
||||
|
||||
// Check file paths in arguments if in sandbox mode
|
||||
if self.config.sandbox_mode {
|
||||
self.check_paths_in_arguments(arguments)?;
|
||||
}
|
||||
|
||||
// Check document size limits for open/create operations
|
||||
if command == "open_document" {
|
||||
if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) {
|
||||
self.check_file_size(path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_paths_in_arguments(&self, arguments: &serde_json::Value) -> Result<(), SecurityError> {
|
||||
// Recursively check all string values that look like paths
|
||||
match arguments {
|
||||
serde_json::Value::String(s) => {
|
||||
if s.contains('/') || s.contains('\\') {
|
||||
let path = std::path::Path::new(s);
|
||||
if !self.config.is_path_allowed(path) {
|
||||
return Err(SecurityError::PathNotAllowed(s.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for value in map.values() {
|
||||
self.check_paths_in_arguments(value)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for value in arr {
|
||||
self.check_paths_in_arguments(value)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_file_size(&self, path: &str) -> Result<(), SecurityError> {
|
||||
let file_path = std::path::Path::new(path);
|
||||
if let Ok(metadata) = std::fs::metadata(file_path) {
|
||||
if metadata.len() as usize > self.config.max_document_size {
|
||||
return Err(SecurityError::FileTooLarge {
|
||||
size: metadata.len() as usize,
|
||||
max_size: self.config.max_document_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecurityError {
|
||||
#[error("Command not allowed: {0}")]
|
||||
CommandNotAllowed(String),
|
||||
|
||||
#[error("Path not allowed in sandbox mode: {0}")]
|
||||
PathNotAllowed(String),
|
||||
|
||||
#[error("File too large: {size} bytes (max: {max_size} bytes)")]
|
||||
FileTooLarge { size: usize, max_size: usize },
|
||||
|
||||
#[error("Maximum number of open documents exceeded")]
|
||||
TooManyDocuments,
|
||||
|
||||
#[error("Operation requires external tools which are disabled")]
|
||||
ExternalToolsDisabled,
|
||||
|
||||
#[error("Operation requires network access which is disabled")]
|
||||
NetworkDisabled,
|
||||
}
|
||||
+916
@@ -0,0 +1,916 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from docx_tools import DocxToolsProvider
|
||||
from security import SecurityConfig, is_command_allowed
|
||||
from templates import list_templates
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("py-docx-mcp")
|
||||
|
||||
TEMPLATES_DIR = os.getenv("DOCX_MCP_TEMPLATES_DIR", "/templates")
|
||||
|
||||
|
||||
def build_tools_list(provider: DocxToolsProvider, security_config: SecurityConfig) -> Dict[str, Any]:
|
||||
"""
|
||||
Build the tools list returned by tools/list,
|
||||
matching the shape expected by OpenWebUI.
|
||||
"""
|
||||
|
||||
def allowed(name: str) -> bool:
|
||||
return is_command_allowed(name, security_config)
|
||||
|
||||
tools = []
|
||||
|
||||
def add(name: str, desc: str, required: list[str],
|
||||
extra_props: Optional[Dict[str, Any]] = None):
|
||||
if not allowed(name):
|
||||
return
|
||||
props = dict(extra_props or {})
|
||||
for r in required:
|
||||
if r not in props:
|
||||
props[r] = {"type": "string"}
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
"required": required,
|
||||
},
|
||||
})
|
||||
|
||||
add("create_document",
|
||||
"Create a new empty DOCX document",
|
||||
[])
|
||||
|
||||
add("open_document",
|
||||
"Open an existing DOCX document",
|
||||
["path"],
|
||||
{"path": {"type": "string", "description": "Path to the DOCX file"}})
|
||||
|
||||
add("add_paragraph",
|
||||
"Add a paragraph with optional styling to the document",
|
||||
["document_id", "text"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"text": {"type": "string"},
|
||||
"style": {
|
||||
"type": "object",
|
||||
"description": "Paragraph style options (font_family, font_size, bold, italic, underline, color, alignment)"
|
||||
},
|
||||
"return_content": {"type": "boolean", "description": "Return document content as base64", "default": False},
|
||||
})
|
||||
|
||||
add("add_heading",
|
||||
"Add a heading to the document",
|
||||
["document_id", "text", "level"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"text": {"type": "string"},
|
||||
"level": {"type": "integer", "description": "Heading level (1-6)"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_table",
|
||||
"Add a table to the document",
|
||||
["document_id", "rows"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"rows": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}},
|
||||
"headers": {"type": "array", "items": {"type": "string"}},
|
||||
"border_style": {"type": "string"},
|
||||
"col_widths": {"type": "array", "items": {"type": "integer"}},
|
||||
"cell_shading": {"type": "string"},
|
||||
"merges": {"type": "array", "items": {"type": "object"}},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_section_break",
|
||||
"Insert a section break with optional page setup",
|
||||
["document_id"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"page_size": {"type": "string"},
|
||||
"orientation": {"type": "string"},
|
||||
"margins": {"type": "object"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_list",
|
||||
"Add a bulleted or numbered list to the document",
|
||||
["document_id", "items"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"ordered": {"type": "boolean", "default": False},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_list_item",
|
||||
"Add a single list item with a specific level",
|
||||
["document_id", "text"],
|
||||
{
|
||||
"document_id": {"type": "string"},
|
||||
"text": {"type": "string"},
|
||||
"level": {"type": "integer", "default": 0},
|
||||
"ordered": {"type": "boolean", "default": False},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_page_break",
|
||||
"Add a page break to the document",
|
||||
["document_id"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("insert_toc",
|
||||
"Insert a Table of Contents placeholder",
|
||||
["document_id"],
|
||||
{
|
||||
"from_level": {"type": "integer", "default": 1},
|
||||
"to_level": {"type": "integer", "default": 3},
|
||||
"right_align_dots": {"type": "boolean", "default": True},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("insert_bookmark_after_heading",
|
||||
"Insert a bookmark immediately after the first matching heading",
|
||||
["document_id", "heading_text", "name"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("set_header",
|
||||
"Set the document header",
|
||||
["document_id", "text"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("set_footer",
|
||||
"Set the document footer",
|
||||
["document_id", "text"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("set_page_numbering",
|
||||
"Set a simple page numbering text in header or footer",
|
||||
["document_id"],
|
||||
{
|
||||
"location": {"type": "string", "default": "footer"},
|
||||
"template": {"type": "string"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("embed_page_number_fields",
|
||||
"Replace placeholder 'Page {PAGE} of {PAGES}' with Word field codes (best-effort)",
|
||||
["document_id"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("add_image",
|
||||
"Insert an image into the document",
|
||||
["document_id", "data_base64"],
|
||||
{
|
||||
"data_base64": {"type": "string"},
|
||||
"width": {"type": "integer"},
|
||||
"height": {"type": "integer"},
|
||||
"alt_text": {"type": "string"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("add_hyperlink",
|
||||
"Insert a hyperlink into the document",
|
||||
["document_id", "text", "url"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("find_and_replace",
|
||||
"Find and replace text in the document",
|
||||
["document_id", "find_text", "replace_text"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("find_and_replace_advanced",
|
||||
"Find/replace with regex, case, whole-word, preserving runs",
|
||||
["document_id", "pattern", "replacement"],
|
||||
{
|
||||
"case_sensitive": {"type": "boolean", "default": False},
|
||||
"whole_word": {"type": "boolean", "default": False},
|
||||
"use_regex": {"type": "boolean", "default": False},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("apply_paragraph_format",
|
||||
"Apply paragraph formatting to paragraphs matching a simple selector",
|
||||
["document_id"],
|
||||
{
|
||||
"contains": {"type": "string"},
|
||||
"format": {"type": "object"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("extract_text",
|
||||
"Extract all text content from the document",
|
||||
["document_id"])
|
||||
|
||||
add("get_tables",
|
||||
"List tables with dimensions, merges, and cell content",
|
||||
["document_id"])
|
||||
|
||||
add("list_images",
|
||||
"List images with width/height and alt text",
|
||||
["document_id"])
|
||||
|
||||
add("list_hyperlinks",
|
||||
"List hyperlinks in the document",
|
||||
["document_id"])
|
||||
|
||||
add("get_fields_summary",
|
||||
"Summarize Word fields (PAGE, NUMPAGES, TOC) in document and headers/footers",
|
||||
["document_id"])
|
||||
|
||||
add("strip_personal_info",
|
||||
"Remove personal info from metadata and core.xml (best-effort)",
|
||||
["document_id"])
|
||||
|
||||
add("get_metadata",
|
||||
"Get document metadata",
|
||||
["document_id"])
|
||||
|
||||
add("save_document",
|
||||
"Save the document to a specific path and return its content",
|
||||
["document_id", "output_path"],
|
||||
{"return_content": {"type": "boolean", "default": True}})
|
||||
|
||||
add("close_document",
|
||||
"Close the document and free resources",
|
||||
["document_id"])
|
||||
|
||||
add("list_documents",
|
||||
"List all open documents",
|
||||
[])
|
||||
|
||||
add("convert_to_pdf",
|
||||
"Convert a DOCX document to PDF and return the file",
|
||||
["document_id", "output_path"],
|
||||
{
|
||||
"prefer_external": {"type": "boolean", "default": False},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
add("export_pdf_with_field_refresh",
|
||||
"Embed page fields then export to PDF (hi-fidelity when available)",
|
||||
["document_id", "output_path"],
|
||||
{
|
||||
"prefer_external": {"type": "boolean", "default": True},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
add("convert_to_images",
|
||||
"Convert a DOCX document to images (one per page) and return them",
|
||||
["document_id", "output_dir"],
|
||||
{
|
||||
"format": {"type": "string", "default": "png"},
|
||||
"dpi": {"type": "integer", "default": 150},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
add("convert_to_images_with_preference",
|
||||
"Convert DOCX to images, preferring external hi-fidelity path",
|
||||
["document_id", "output_dir"],
|
||||
{
|
||||
"format": {"type": "string", "default": "png"},
|
||||
"dpi": {"type": "integer", "default": 150},
|
||||
"prefer_external": {"type": "boolean", "default": True},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
add("merge_documents",
|
||||
"Merge multiple DOCX documents into one and return the result",
|
||||
["document_ids", "output_path"],
|
||||
{
|
||||
"document_ids": {"type": "array", "items": {"type": "string"}},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
add("split_document",
|
||||
"Split a document at page breaks and return parts",
|
||||
["document_id", "output_dir"],
|
||||
{"return_content": {"type": "boolean", "default": True}})
|
||||
|
||||
add("get_document_structure",
|
||||
"Get the structural overview of the document (headings, sections, etc.)",
|
||||
["document_id"])
|
||||
|
||||
add("get_outline",
|
||||
"Return heading outline with range_ids",
|
||||
["document_id"])
|
||||
|
||||
add("get_ranges",
|
||||
"Resolve a selector to range_ids",
|
||||
["document_id", "selector"])
|
||||
|
||||
add("replace_range_text",
|
||||
"Replace text in a paragraph/heading by range_id",
|
||||
["document_id", "range_id", "text"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("set_table_cell_text",
|
||||
"Set text in a table cell by indices",
|
||||
["document_id", "table_index", "row", "col", "text"],
|
||||
{
|
||||
"table_index": {"type": "integer"},
|
||||
"row": {"type": "integer"},
|
||||
"col": {"type": "integer"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("get_document_properties",
|
||||
"Get document properties (title, subject, author, timestamps)",
|
||||
["document_id"])
|
||||
|
||||
add("set_document_properties",
|
||||
"Set document properties (title, subject, author)",
|
||||
["document_id"],
|
||||
{
|
||||
"title": {"type": "string"},
|
||||
"subject": {"type": "string"},
|
||||
"author": {"type": "string"},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("insert_after_heading",
|
||||
"Insert a paragraph after the first heading that matches text",
|
||||
["document_id", "heading_text", "text"],
|
||||
{"return_content": {"type": "boolean", "default": False}})
|
||||
|
||||
add("sanitize_external_links",
|
||||
"Remove external hyperlinks (http/https)",
|
||||
["document_id"])
|
||||
|
||||
add("redact_text",
|
||||
"Redact text using regex/whole-word with █ character",
|
||||
["document_id", "pattern"],
|
||||
{
|
||||
"use_regex": {"type": "boolean", "default": False},
|
||||
"whole_word": {"type": "boolean", "default": False},
|
||||
"case_sensitive": {"type": "boolean", "default": False},
|
||||
"return_content": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("analyze_formatting",
|
||||
"Analyze the formatting used throughout the document",
|
||||
["document_id"])
|
||||
|
||||
add("get_word_count",
|
||||
"Get detailed word count statistics for the document",
|
||||
["document_id"])
|
||||
|
||||
add("search_text",
|
||||
"Search for text patterns in the document",
|
||||
["document_id", "search_term"],
|
||||
{
|
||||
"case_sensitive": {"type": "boolean", "default": False},
|
||||
"whole_word": {"type": "boolean", "default": False},
|
||||
})
|
||||
|
||||
add("export_to_markdown",
|
||||
"Export document content to Markdown format and return the file",
|
||||
["document_id", "output_path"],
|
||||
{"return_content": {"type": "boolean", "default": True}})
|
||||
|
||||
add("export_to_html",
|
||||
"Export document content to HTML format and return the file",
|
||||
["document_id", "output_path"],
|
||||
{"return_content": {"type": "boolean", "default": True}})
|
||||
|
||||
add("get_security_info",
|
||||
"Get information about current security settings and restrictions",
|
||||
[])
|
||||
|
||||
add("get_storage_info",
|
||||
"Get information about temporary storage usage",
|
||||
[])
|
||||
|
||||
add("list_templates",
|
||||
"List available document templates from the templates directory",
|
||||
[])
|
||||
|
||||
add("open_template",
|
||||
"Open a template document by name from the templates directory",
|
||||
["name"])
|
||||
|
||||
add("generate_from_template",
|
||||
"Generate a new document from a template and return the file",
|
||||
["template_name", "output_path", "fields"],
|
||||
{
|
||||
"fields": {"type": "object"},
|
||||
"return_content": {"type": "boolean", "default": True},
|
||||
})
|
||||
|
||||
return {"tools": tools}
|
||||
|
||||
|
||||
def make_app() -> FastAPI:
|
||||
app = FastAPI(title="py-docx-mcp")
|
||||
|
||||
readonly_mode = os.getenv("DOCX_MCP_READONLY", "false").lower() in ("true", "1")
|
||||
sandbox_mode = os.getenv("DOCX_MCP_SANDBOX", "true").lower() in ("true", "1")
|
||||
allow_external_tools = os.getenv("DOCX_MCP_ALLOW_EXTERNAL_TOOLS", "false").lower() in ("true", "1")
|
||||
allow_network = os.getenv("DOCX_MCP_ALLOW_NETWORK", "false").lower() in ("true", "1")
|
||||
max_document_size = int(os.getenv("DOCX_MCP_MAX_SIZE", "104857600"))
|
||||
max_open_documents = int(os.getenv("DOCX_MCP_MAX_DOCS", "30"))
|
||||
|
||||
api_key = os.getenv("DOCX_MCP_API_KEY", "").strip()
|
||||
|
||||
security_config = SecurityConfig(
|
||||
readonly_mode=readonly_mode,
|
||||
sandbox_mode=sandbox_mode,
|
||||
allow_external_tools=allow_external_tools,
|
||||
allow_network=allow_network,
|
||||
max_document_size=max_document_size,
|
||||
max_open_documents=max_open_documents,
|
||||
)
|
||||
|
||||
provider = DocxToolsProvider(
|
||||
security_config=security_config,
|
||||
templates_dir=TEMPLATES_DIR,
|
||||
)
|
||||
|
||||
tools_list = build_tools_list(provider, security_config)
|
||||
|
||||
def get_bearer_token(request: Request) -> Optional[str]:
|
||||
auth = (request.headers.get("Authorization") or "").strip()
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[len("Bearer "):].strip()
|
||||
return None
|
||||
|
||||
def require_auth(request: Request) -> bool:
|
||||
if not api_key:
|
||||
return True
|
||||
token = get_bearer_token(request)
|
||||
if not token or token != api_key:
|
||||
return False
|
||||
return True
|
||||
|
||||
@app.get("/")
|
||||
async def root(request: Request):
|
||||
if not require_auth(request):
|
||||
return JSONResponse(status_code=401, content={"error": "Missing or invalid API key"})
|
||||
return {
|
||||
"service": "py-docx-mcp",
|
||||
"transport": "streamable-http",
|
||||
"docs": "Use POST / with MCP JSON-RPC (initialize, tools/list, tools/call).",
|
||||
}
|
||||
|
||||
@app.post("/")
|
||||
async def mcp_endpoint(request: Request):
|
||||
if not require_auth(request):
|
||||
return JSONResponse(status_code=401, content={"error": "Missing or invalid API key"})
|
||||
|
||||
body = await request.json()
|
||||
method = body.get("method")
|
||||
params = body.get("params") or {}
|
||||
req_id = body.get("id")
|
||||
|
||||
# MCP: initialize
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2025-11-25",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {
|
||||
"name": "py-docx-mcp",
|
||||
"version": "0.1.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# MCP: tools/list
|
||||
if method == "tools/list":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": tools_list,
|
||||
}
|
||||
|
||||
# MCP: tools/call
|
||||
if method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
tool_args = params.get("arguments") or {}
|
||||
|
||||
# Security check: only allowed commands
|
||||
if not is_command_allowed(tool_name, security_config):
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": f"Command '{tool_name}' not allowed by security policy",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
result = call_tool_impl(tool_name, tool_args, provider)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)),
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": str(e),
|
||||
},
|
||||
}
|
||||
|
||||
# Unknown method
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Unknown method: " + str(method)},
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def call_tool_impl(name: str, args: Dict[str, Any], provider: DocxToolsProvider) -> Any:
|
||||
# Delegate to provider methods, matching names.
|
||||
# This central dispatcher keeps tool signatures in one place.
|
||||
|
||||
if name == "create_document":
|
||||
return provider.create_document()
|
||||
|
||||
if name == "open_document":
|
||||
return provider.open_document(args["path"])
|
||||
|
||||
if name == "add_paragraph":
|
||||
return provider.add_paragraph(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
args.get("style") or {},
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_heading":
|
||||
return provider.add_heading(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
int(args["level"]),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_table":
|
||||
return provider.add_table(
|
||||
args["document_id"],
|
||||
args["rows"],
|
||||
headers=args.get("headers"),
|
||||
border_style=args.get("border_style"),
|
||||
col_widths=args.get("col_widths"),
|
||||
cell_shading=args.get("cell_shading"),
|
||||
merges=args.get("merges"),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_section_break":
|
||||
return provider.add_section_break(
|
||||
args["document_id"],
|
||||
page_size=args.get("page_size"),
|
||||
orientation=args.get("orientation"),
|
||||
margins=args.get("margins"),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_list":
|
||||
return provider.add_list(
|
||||
args["document_id"],
|
||||
args["items"],
|
||||
ordered=bool(args.get("ordered", False)),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_list_item":
|
||||
return provider.add_list_item(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
level=int(args.get("level", 0)),
|
||||
ordered=bool(args.get("ordered", False)),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_page_break":
|
||||
return provider.add_page_break(
|
||||
args["document_id"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "insert_toc":
|
||||
return provider.insert_toc(
|
||||
args["document_id"],
|
||||
from_level=int(args.get("from_level", 1)),
|
||||
to_level=int(args.get("to_level", 3)),
|
||||
right_align_dots=bool(args.get("right_align_dots", True)),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "insert_bookmark_after_heading":
|
||||
return provider.insert_bookmark_after_heading(
|
||||
args["document_id"],
|
||||
args["heading_text"],
|
||||
args["name"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "set_header":
|
||||
return provider.set_header(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "set_footer":
|
||||
return provider.set_footer(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "set_page_numbering":
|
||||
return provider.set_page_numbering(
|
||||
args["document_id"],
|
||||
location=args.get("location", "footer"),
|
||||
template=args.get("template"),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "embed_page_number_fields":
|
||||
return provider.embed_page_number_fields(
|
||||
args["document_id"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_image":
|
||||
return provider.add_image(
|
||||
args["document_id"],
|
||||
args["data_base64"],
|
||||
width=args.get("width"),
|
||||
height=args.get("height"),
|
||||
alt_text=args.get("alt_text"),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "add_hyperlink":
|
||||
return provider.add_hyperlink(
|
||||
args["document_id"],
|
||||
args["text"],
|
||||
args["url"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "find_and_replace":
|
||||
return provider.find_and_replace(
|
||||
args["document_id"],
|
||||
args["find_text"],
|
||||
args["replace_text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "find_and_replace_advanced":
|
||||
return provider.find_and_replace_advanced(
|
||||
args["document_id"],
|
||||
args["pattern"],
|
||||
args["replacement"],
|
||||
case_sensitive=bool(args.get("case_sensitive", False)),
|
||||
whole_word=bool(args.get("whole_word", False)),
|
||||
use_regex=bool(args.get("use_regex", False)),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "apply_paragraph_format":
|
||||
return provider.apply_paragraph_format(
|
||||
args["document_id"],
|
||||
contains=args.get("contains"),
|
||||
format=args.get("format") or {},
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "extract_text":
|
||||
return provider.extract_text(args["document_id"])
|
||||
|
||||
if name == "get_tables":
|
||||
return provider.get_tables(args["document_id"])
|
||||
|
||||
if name == "list_images":
|
||||
return provider.list_images(args["document_id"])
|
||||
|
||||
if name == "list_hyperlinks":
|
||||
return provider.list_hyperlinks(args["document_id"])
|
||||
|
||||
if name == "get_fields_summary":
|
||||
return provider.get_fields_summary(args["document_id"])
|
||||
|
||||
if name == "strip_personal_info":
|
||||
return provider.strip_personal_info(args["document_id"])
|
||||
|
||||
if name == "get_metadata":
|
||||
return provider.get_metadata(args["document_id"])
|
||||
|
||||
if name == "save_document":
|
||||
return provider.save_document(
|
||||
args["document_id"],
|
||||
args["output_path"],
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "close_document":
|
||||
return provider.close_document(args["document_id"])
|
||||
|
||||
if name == "list_documents":
|
||||
return provider.list_documents()
|
||||
|
||||
if name == "convert_to_pdf":
|
||||
return provider.convert_to_pdf(
|
||||
args["document_id"],
|
||||
args["output_path"],
|
||||
prefer_external=bool(args.get("prefer_external", False)),
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "export_pdf_with_field_refresh":
|
||||
return provider.export_pdf_with_field_refresh(
|
||||
args["document_id"],
|
||||
args["output_path"],
|
||||
prefer_external=bool(args.get("prefer_external", True)),
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "convert_to_images":
|
||||
return provider.convert_to_images(
|
||||
args["document_id"],
|
||||
args["output_dir"],
|
||||
format=args.get("format", "png"),
|
||||
dpi=int(args.get("dpi", 150)),
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "convert_to_images_with_preference":
|
||||
return provider.convert_to_images_with_preference(
|
||||
args["document_id"],
|
||||
args["output_dir"],
|
||||
format=args.get("format", "png"),
|
||||
dpi=int(args.get("dpi", 150)),
|
||||
prefer_external=bool(args.get("prefer_external", True)),
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "merge_documents":
|
||||
return provider.merge_documents(
|
||||
args["document_ids"],
|
||||
args["output_path"],
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "split_document":
|
||||
return provider.split_document(
|
||||
args["document_id"],
|
||||
args["output_dir"],
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "get_document_structure":
|
||||
return provider.get_document_structure(args["document_id"])
|
||||
|
||||
if name == "get_outline":
|
||||
return provider.get_outline(args["document_id"])
|
||||
|
||||
if name == "get_ranges":
|
||||
return provider.get_ranges(args["document_id"], args["selector"])
|
||||
|
||||
if name == "replace_range_text":
|
||||
return provider.replace_range_text(
|
||||
args["document_id"],
|
||||
args["range_id"],
|
||||
args["text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "set_table_cell_text":
|
||||
return provider.set_table_cell_text(
|
||||
args["document_id"],
|
||||
int(args["table_index"]),
|
||||
int(args["row"]),
|
||||
int(args["col"]),
|
||||
args["text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "get_document_properties":
|
||||
return provider.get_document_properties(args["document_id"])
|
||||
|
||||
if name == "set_document_properties":
|
||||
return provider.set_document_properties(
|
||||
args["document_id"],
|
||||
title=args.get("title"),
|
||||
subject=args.get("subject"),
|
||||
author=args.get("author"),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "insert_after_heading":
|
||||
return provider.insert_after_heading(
|
||||
args["document_id"],
|
||||
args["heading_text"],
|
||||
args["text"],
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "sanitize_external_links":
|
||||
return provider.sanitize_external_links(args["document_id"])
|
||||
|
||||
if name == "redact_text":
|
||||
return provider.redact_text(
|
||||
args["document_id"],
|
||||
args["pattern"],
|
||||
use_regex=bool(args.get("use_regex", False)),
|
||||
whole_word=bool(args.get("whole_word", False)),
|
||||
case_sensitive=bool(args.get("case_sensitive", False)),
|
||||
return_content=args.get("return_content", False),
|
||||
)
|
||||
|
||||
if name == "analyze_formatting":
|
||||
return provider.analyze_formatting(args["document_id"])
|
||||
|
||||
if name == "get_word_count":
|
||||
return provider.get_word_count(args["document_id"])
|
||||
|
||||
if name == "search_text":
|
||||
return provider.search_text(
|
||||
args["document_id"],
|
||||
args["search_term"],
|
||||
case_sensitive=bool(args.get("case_sensitive", False)),
|
||||
whole_word=bool(args.get("whole_word", False)),
|
||||
)
|
||||
|
||||
if name == "export_to_markdown":
|
||||
return provider.export_to_markdown(
|
||||
args["document_id"],
|
||||
args["output_path"],
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "export_to_html":
|
||||
return provider.export_to_html(
|
||||
args["document_id"],
|
||||
args["output_path"],
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
if name == "get_security_info":
|
||||
return provider.get_security_info()
|
||||
|
||||
if name == "get_storage_info":
|
||||
return provider.get_storage_info()
|
||||
|
||||
if name == "list_templates":
|
||||
return list_templates(TEMPLATES_DIR)
|
||||
|
||||
if name == "open_template":
|
||||
return provider.open_template(args["name"], TEMPLATES_DIR)
|
||||
|
||||
if name == "generate_from_template":
|
||||
return provider.generate_from_template(
|
||||
args["template_name"],
|
||||
args["output_path"],
|
||||
args.get("fields") or {},
|
||||
return_content=args.get("return_content", True),
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def main():
|
||||
app = make_app()
|
||||
host = os.getenv("DOCX_MCP_HTTP_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("DOCX_MCP_HTTP_PORT", "3000"))
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
|
||||
def list_templates(templates_dir: str) -> dict:
|
||||
if not os.path.isdir(templates_dir):
|
||||
return {"templates": []}
|
||||
templates: List[str] = []
|
||||
for entry in os.listdir(templates_dir):
|
||||
path = os.path.join(templates_dir, entry)
|
||||
if os.path.isfile(path) and entry.lower().endswith(".docx"):
|
||||
templates.append(entry)
|
||||
templates.sort()
|
||||
return {"templates": templates}
|
||||
|
||||
|
||||
def open_template_path(templates_dir: str, name: str) -> str:
|
||||
path = os.path.join(templates_dir, name)
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError(f"Template not found: {name}")
|
||||
return path
|
||||
@@ -1,100 +0,0 @@
|
||||
use docx_mcp::security::{Args, SecurityConfig};
|
||||
use clap::Parser;
|
||||
use std::env;
|
||||
|
||||
fn reset_env() {
|
||||
for (k, _) in env::vars() {
|
||||
if k.starts_with("DOCX_MCP_") {
|
||||
env::remove_var(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_flags_and_lists() {
|
||||
reset_env();
|
||||
|
||||
let argv = [
|
||||
"docx-mcp",
|
||||
"--readonly",
|
||||
"--sandbox",
|
||||
"--no-external-tools",
|
||||
"--no-network",
|
||||
"--whitelist",
|
||||
"open_document,extract_text,get_metadata",
|
||||
"--blacklist",
|
||||
"save_document,add_paragraph",
|
||||
"--max-size",
|
||||
"1048576",
|
||||
"--max-docs",
|
||||
"10",
|
||||
];
|
||||
|
||||
let args = Args::parse_from(&argv);
|
||||
assert!(args.readonly);
|
||||
assert!(args.sandbox);
|
||||
assert!(args.no_external_tools);
|
||||
assert!(args.no_network);
|
||||
assert_eq!(args.max_size, Some(1_048_576));
|
||||
assert_eq!(args.max_docs, Some(10));
|
||||
|
||||
let wl = args.whitelist.clone().unwrap();
|
||||
assert_eq!(wl, vec![
|
||||
"open_document".to_string(),
|
||||
"extract_text".to_string(),
|
||||
"get_metadata".to_string(),
|
||||
]);
|
||||
|
||||
let bl = args.blacklist.clone().unwrap();
|
||||
assert_eq!(bl, vec![
|
||||
"save_document".to_string(),
|
||||
"add_paragraph".to_string(),
|
||||
]);
|
||||
|
||||
let cfg = SecurityConfig::from_args(args);
|
||||
assert!(cfg.readonly_mode);
|
||||
assert!(cfg.sandbox_mode);
|
||||
assert!(!cfg.allow_external_tools);
|
||||
assert!(!cfg.allow_network);
|
||||
assert_eq!(cfg.max_document_size, 1_048_576);
|
||||
assert_eq!(cfg.max_open_documents, 10);
|
||||
|
||||
let wlset = cfg.command_whitelist.unwrap();
|
||||
assert!(wlset.contains("open_document"));
|
||||
assert!(wlset.contains("extract_text"));
|
||||
assert!(wlset.contains("get_metadata"));
|
||||
|
||||
let blset = cfg.command_blacklist.unwrap();
|
||||
assert!(blset.contains("save_document"));
|
||||
assert!(blset.contains("add_paragraph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_from_environment() {
|
||||
reset_env();
|
||||
|
||||
env::set_var("DOCX_MCP_READONLY", "true");
|
||||
env::set_var("DOCX_MCP_SANDBOX", "true");
|
||||
env::set_var("DOCX_MCP_NO_EXTERNAL_TOOLS", "true");
|
||||
env::set_var("DOCX_MCP_NO_NETWORK", "true");
|
||||
env::set_var("DOCX_MCP_WHITELIST", "open_document,extract_text");
|
||||
env::set_var("DOCX_MCP_BLACKLIST", "save_document");
|
||||
env::set_var("DOCX_MCP_MAX_SIZE", "2048");
|
||||
env::set_var("DOCX_MCP_MAX_DOCS", "7");
|
||||
|
||||
let cfg = SecurityConfig::from_env();
|
||||
|
||||
assert!(cfg.readonly_mode);
|
||||
assert!(cfg.sandbox_mode);
|
||||
assert!(!cfg.allow_external_tools);
|
||||
assert!(!cfg.allow_network);
|
||||
assert_eq!(cfg.max_document_size, 2048);
|
||||
assert_eq!(cfg.max_open_documents, 7);
|
||||
|
||||
let wl = cfg.command_whitelist.unwrap();
|
||||
assert!(wl.contains("open_document"));
|
||||
assert!(wl.contains("extract_text"));
|
||||
|
||||
let bl = cfg.command_blacklist.unwrap();
|
||||
assert!(bl.contains("save_document"));
|
||||
}
|
||||
@@ -1,497 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use docx_mcp::pure_converter::PureRustConverter;
|
||||
use tempfile::TempDir;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rstest::*;
|
||||
|
||||
fn setup_test_handler_with_content() -> (DocxHandler, String, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Add comprehensive content for testing
|
||||
handler.add_heading(&doc_id, "Test Document Title", 1).unwrap();
|
||||
handler.add_paragraph(&doc_id, "This is a comprehensive test document with various content types.", None).unwrap();
|
||||
|
||||
handler.add_heading(&doc_id, "Table Example", 2).unwrap();
|
||||
let table_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()],
|
||||
vec!["Widget A".to_string(), "$10.00".to_string(), "5".to_string()],
|
||||
vec!["Widget B".to_string(), "$15.00".to_string(), "3".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Product".to_string(), "Price".to_string(), "Quantity".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, table_data).unwrap();
|
||||
|
||||
handler.add_heading(&doc_id, "List Example", 2).unwrap();
|
||||
let list_items = vec![
|
||||
"First important point".to_string(),
|
||||
"Second key feature".to_string(),
|
||||
"Third critical aspect".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, list_items, false).unwrap();
|
||||
|
||||
handler.add_paragraph(&doc_id, "Conclusion: This document demonstrates various formatting capabilities.", None).unwrap();
|
||||
|
||||
(handler, doc_id, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_converter_creation() {
|
||||
let converter = PureRustConverter::new();
|
||||
// Just verify it can be created without panicking
|
||||
assert!(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_from_docx() -> Result<()> {
|
||||
let (handler, doc_id, _temp_dir) = setup_test_handler_with_content();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
|
||||
|
||||
// Should contain all the content we added
|
||||
assert!(extracted_text.contains("Test Document Title"));
|
||||
assert!(extracted_text.contains("comprehensive test document"));
|
||||
assert!(extracted_text.contains("Table Example"));
|
||||
assert!(extracted_text.contains("Widget A"));
|
||||
assert!(extracted_text.contains("First important point"));
|
||||
assert!(extracted_text.contains("Conclusion"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_empty_document() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
|
||||
|
||||
// Empty document should return empty or whitespace-only text
|
||||
assert!(extracted_text.trim().is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_docx_to_pdf_basic() -> Result<()> {
|
||||
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_path = temp_dir.path().join("test_output.pdf");
|
||||
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
|
||||
|
||||
// Verify PDF file was created
|
||||
assert!(output_path.exists());
|
||||
|
||||
// Check file size is reasonable (should be larger than empty PDF)
|
||||
let file_size = fs::metadata(&output_path)?.len();
|
||||
assert!(file_size > 1000); // PDF should be at least 1KB
|
||||
|
||||
// Verify it's actually a PDF file (starts with PDF signature)
|
||||
let pdf_content = fs::read(&output_path)?;
|
||||
assert!(pdf_content.starts_with(b"%PDF"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_docx_to_pdf_with_complex_content() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Add content with special characters and formatting
|
||||
handler.add_paragraph(&doc_id, "Special characters: éñüñ, 中文, русский, العربية", None)?;
|
||||
|
||||
let style = DocxStyle {
|
||||
font_family: Some("Arial".to_string()),
|
||||
font_size: Some(16),
|
||||
bold: Some(true),
|
||||
italic: Some(false),
|
||||
underline: Some(true),
|
||||
color: Some("#FF0000".to_string()),
|
||||
alignment: Some("center".to_string()),
|
||||
line_spacing: Some(1.5),
|
||||
};
|
||||
handler.add_paragraph(&doc_id, "Bold and underlined text", Some(style))?;
|
||||
|
||||
// Add multiple headings
|
||||
for level in 1..=3 {
|
||||
handler.add_heading(&doc_id, &format!("Heading Level {}", level), level)?;
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_path = temp_dir.path().join("complex_output.pdf");
|
||||
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
|
||||
|
||||
assert!(output_path.exists());
|
||||
let file_size = fs::metadata(&output_path)?.len();
|
||||
assert!(file_size > 2000); // Should be larger due to more content
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_docx_to_images() -> Result<()> {
|
||||
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_dir = temp_dir.path().join("images");
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let image_paths = converter.convert_docx_to_images(&metadata.path, &output_dir)?;
|
||||
|
||||
// Should generate at least one image
|
||||
assert!(!image_paths.is_empty());
|
||||
|
||||
// Verify all generated images exist
|
||||
for image_path in &image_paths {
|
||||
assert!(image_path.exists(), "Generated image should exist: {:?}", image_path);
|
||||
|
||||
let file_size = fs::metadata(image_path)?.len();
|
||||
assert!(file_size > 100, "Image file should have reasonable size");
|
||||
|
||||
// Verify it's a PNG file (our default format)
|
||||
if image_path.extension().and_then(|s| s.to_str()) == Some("png") {
|
||||
let image_content = fs::read(image_path)?;
|
||||
assert!(image_content.starts_with(&[0x89, 0x50, 0x4E, 0x47]), "Should be valid PNG");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_docx_to_images_custom_format() -> Result<()> {
|
||||
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_dir = temp_dir.path().join("jpeg_images");
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, "jpeg", 150)?;
|
||||
|
||||
assert!(!image_paths.is_empty());
|
||||
|
||||
for image_path in &image_paths {
|
||||
assert!(image_path.exists());
|
||||
|
||||
// Verify JPEG format
|
||||
if image_path.extension().and_then(|s| s.to_str()) == Some("jpg") ||
|
||||
image_path.extension().and_then(|s| s.to_str()) == Some("jpeg") {
|
||||
let image_content = fs::read(image_path)?;
|
||||
assert!(image_content.starts_with(&[0xFF, 0xD8, 0xFF]), "Should be valid JPEG");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pdf_generation_with_embedded_fonts() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Add text that might require different fonts
|
||||
handler.add_paragraph(&doc_id, "Regular ASCII text", None)?;
|
||||
handler.add_paragraph(&doc_id, "Unicode: àáâãäå çèéêë ìíîï ñòóôõö ùúûü ýÿ", None)?;
|
||||
handler.add_paragraph(&doc_id, "Math symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞", None)?;
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_path = temp_dir.path().join("embedded_fonts.pdf");
|
||||
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
|
||||
|
||||
assert!(output_path.exists());
|
||||
let file_size = fs::metadata(&output_path)?.len();
|
||||
assert!(file_size > 5000); // Should be larger due to embedded fonts
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_conversion() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
|
||||
// Create multiple documents
|
||||
let mut doc_paths = Vec::new();
|
||||
for i in 0..3 {
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
doc_paths.push(metadata.path);
|
||||
}
|
||||
|
||||
let converter = PureRustConverter::new();
|
||||
let output_dir = temp_dir.path().join("batch_output");
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// Convert all documents to PDF
|
||||
for (i, doc_path) in doc_paths.iter().enumerate() {
|
||||
let output_path = output_dir.join(format!("document_{}.pdf", i));
|
||||
converter.convert_docx_to_pdf(doc_path, &output_path)?;
|
||||
|
||||
assert!(output_path.exists());
|
||||
}
|
||||
|
||||
// Verify all PDFs were created
|
||||
let pdf_files: Vec<_> = fs::read_dir(&output_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
|
||||
.collect();
|
||||
|
||||
assert_eq!(pdf_files.len(), 3);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling_invalid_docx() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
// Create a fake DOCX file (actually just text)
|
||||
let fake_docx = temp_dir.path().join("fake.docx");
|
||||
fs::write(&fake_docx, "This is not a DOCX file").unwrap();
|
||||
|
||||
// Should handle the error gracefully
|
||||
let result = converter.extract_text_from_docx(&fake_docx);
|
||||
assert!(result.is_err());
|
||||
|
||||
let output_path = temp_dir.path().join("output.pdf");
|
||||
let result = converter.convert_docx_to_pdf(&fake_docx, &output_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling_nonexistent_file() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let nonexistent = temp_dir.path().join("nonexistent.docx");
|
||||
|
||||
let result = converter.extract_text_from_docx(&nonexistent);
|
||||
assert!(result.is_err());
|
||||
|
||||
let output_path = temp_dir.path().join("output.pdf");
|
||||
let result = converter.convert_docx_to_pdf(&nonexistent, &output_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_document_conversion() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Create a large document with many pages
|
||||
for i in 0..50 {
|
||||
handler.add_heading(&doc_id, &format!("Section {}", i + 1), 1)?;
|
||||
|
||||
for j in 0..10 {
|
||||
let content = format!("This is paragraph {} in section {}. It contains enough text to make the document substantial and test the conversion capabilities with larger files.", j + 1, i + 1);
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
}
|
||||
|
||||
if i % 10 == 9 {
|
||||
handler.add_page_break(&doc_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
// Test PDF conversion
|
||||
let pdf_path = temp_dir.path().join("large_document.pdf");
|
||||
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
|
||||
|
||||
assert!(pdf_path.exists());
|
||||
let pdf_size = fs::metadata(&pdf_path)?.len();
|
||||
assert!(pdf_size > 50000); // Should be a substantial PDF
|
||||
|
||||
// Test image conversion (but only first few pages to avoid excessive test time)
|
||||
let images_dir = temp_dir.path().join("large_images");
|
||||
fs::create_dir_all(&images_dir)?;
|
||||
|
||||
let image_paths = converter.convert_docx_to_images(&metadata.path, &images_dir)?;
|
||||
assert!(!image_paths.is_empty());
|
||||
|
||||
// Should generate multiple images for multiple pages
|
||||
assert!(image_paths.len() >= 2);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_extraction_accuracy() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
// Add specific test content
|
||||
let test_sentences = vec![
|
||||
"The quick brown fox jumps over the lazy dog.",
|
||||
"Pack my box with five dozen liquor jugs.",
|
||||
"How vexingly quick daft zebras jump!",
|
||||
"Sphinx of black quartz, judge my vow.",
|
||||
];
|
||||
|
||||
for sentence in &test_sentences {
|
||||
handler.add_paragraph(&doc_id, sentence, None)?;
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let extracted_text = converter.extract_text_from_docx(&metadata.path)?;
|
||||
|
||||
// Verify all sentences are present in the extracted text
|
||||
for sentence in &test_sentences {
|
||||
assert!(extracted_text.contains(sentence),
|
||||
"Extracted text should contain: '{}'", sentence);
|
||||
}
|
||||
|
||||
// Check word count accuracy
|
||||
let expected_words: usize = test_sentences.iter()
|
||||
.map(|s| s.split_whitespace().count())
|
||||
.sum();
|
||||
let extracted_words = extracted_text.split_whitespace().count();
|
||||
|
||||
// Should be approximately equal (allowing for minor differences)
|
||||
let word_diff = if extracted_words > expected_words {
|
||||
extracted_words - expected_words
|
||||
} else {
|
||||
expected_words - extracted_words
|
||||
};
|
||||
assert!(word_diff <= 5, "Word count difference too large: expected ~{}, got {}", expected_words, extracted_words);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversion_with_different_page_sizes() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
|
||||
handler.add_paragraph(&doc_id, "This document tests page size handling during conversion.", None)?;
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
// Test different output formats and sizes
|
||||
let test_cases = vec![
|
||||
("a4.pdf", "A4"),
|
||||
("letter.pdf", "Letter"),
|
||||
("legal.pdf", "Legal"),
|
||||
];
|
||||
|
||||
for (filename, _page_size) in test_cases {
|
||||
let output_path = temp_dir.path().join(filename);
|
||||
|
||||
// Note: In a full implementation, you'd pass page_size to the converter
|
||||
converter.convert_docx_to_pdf(&metadata.path, &output_path)?;
|
||||
|
||||
assert!(output_path.exists());
|
||||
let file_size = fs::metadata(&output_path)?.len();
|
||||
assert!(file_size > 500); // Reasonable minimum size
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Parametrized test for different image formats
|
||||
#[rstest]
|
||||
#[case("png", &[0x89, 0x50, 0x4E, 0x47])]
|
||||
#[case("jpeg", &[0xFF, 0xD8, 0xFF])]
|
||||
fn test_image_format_conversion(#[case] format: &str, #[case] signature: &[u8]) -> Result<()> {
|
||||
let (handler, doc_id, temp_dir) = setup_test_handler_with_content();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let output_dir = temp_dir.path().join(format!("{}_images", format));
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
let image_paths = converter.convert_docx_to_images_with_format(&metadata.path, &output_dir, format, 100)?;
|
||||
|
||||
assert!(!image_paths.is_empty());
|
||||
|
||||
for image_path in &image_paths {
|
||||
assert!(image_path.exists());
|
||||
|
||||
let image_content = fs::read(image_path)?;
|
||||
assert!(image_content.starts_with(signature),
|
||||
"Image should have correct format signature for {}", format);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversion_thread_safety() -> Result<()> {
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_path = Arc::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let handles: Vec<_> = (0..3).map(|i| {
|
||||
let temp_path = Arc::clone(&temp_path);
|
||||
thread::spawn(move || -> Result<()> {
|
||||
let mut handler = DocxHandler::new_with_temp_dir(&temp_path)?;
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
handler.add_paragraph(&doc_id, &format!("Thread {} test content", i), None)?;
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let pdf_path = temp_path.join(format!("thread_{}.pdf", i));
|
||||
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
|
||||
|
||||
assert!(pdf_path.exists());
|
||||
Ok(())
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Wait for all threads to complete
|
||||
for handle in handles {
|
||||
handle.join().unwrap()?;
|
||||
}
|
||||
|
||||
// Verify all PDFs were created
|
||||
let pdf_count = fs::read_dir(&temp_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
|
||||
.count();
|
||||
|
||||
assert_eq!(pdf_count, 3);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use tempfile::TempDir;
|
||||
use std::path::PathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rstest::*;
|
||||
use chrono::Utc;
|
||||
|
||||
fn setup_test_handler() -> (DocxHandler, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
(handler, temp_dir)
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn handler_and_doc() -> (DocxHandler, String, TempDir) {
|
||||
let (mut handler, temp_dir) = setup_test_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
(handler, doc_id, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_document() {
|
||||
let (mut handler, _temp_dir) = setup_test_handler();
|
||||
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
assert!(!doc_id.is_empty());
|
||||
|
||||
// Document should be in the handler's registry
|
||||
assert!(handler.documents.contains_key(&doc_id));
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id).unwrap();
|
||||
assert_eq!(metadata.id, doc_id);
|
||||
assert!(metadata.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_paragraph() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let result = handler.add_paragraph(&doc_id, "Test paragraph", None);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify content was added by extracting text
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("Test paragraph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_paragraph_with_style() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let style = DocxStyle {
|
||||
font_family: Some("Arial".to_string()),
|
||||
font_size: Some(14),
|
||||
bold: Some(true),
|
||||
italic: Some(false),
|
||||
underline: Some(false),
|
||||
color: Some("#FF0000".to_string()),
|
||||
alignment: Some("center".to_string()),
|
||||
line_spacing: Some(1.5),
|
||||
};
|
||||
|
||||
let result = handler.add_paragraph(&doc_id, "Styled paragraph", Some(style));
|
||||
assert!(result.is_ok());
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("Styled paragraph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_heading() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
for level in 1..=6 {
|
||||
let heading_text = format!("Heading Level {}", level);
|
||||
let result = handler.add_heading(&doc_id, &heading_text, level);
|
||||
assert!(result.is_ok(), "Failed to add heading level {}", level);
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains(&heading_text));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_table() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let table_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
|
||||
vec!["John".to_string(), "30".to_string(), "NYC".to_string()],
|
||||
vec!["Jane".to_string(), "25".to_string(), "LA".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
|
||||
let result = handler.add_table(&doc_id, table_data);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("John"));
|
||||
assert!(text.contains("Jane"));
|
||||
assert!(text.contains("NYC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_list() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let items = vec![
|
||||
"First item".to_string(),
|
||||
"Second item".to_string(),
|
||||
"Third item".to_string(),
|
||||
];
|
||||
|
||||
// Test unordered list
|
||||
let result = handler.add_list(&doc_id, items.clone(), false);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Test ordered list
|
||||
let result = handler.add_list(&doc_id, items.clone(), true);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("First item"));
|
||||
assert!(text.contains("Second item"));
|
||||
assert!(text.contains("Third item"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_header_footer() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let header_result = handler.set_header(&doc_id, "Document Header");
|
||||
assert!(header_result.is_ok());
|
||||
|
||||
let footer_result = handler.set_footer(&doc_id, "Document Footer");
|
||||
assert!(footer_result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_page_break() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
handler.add_paragraph(&doc_id, "Before page break", None).unwrap();
|
||||
|
||||
let result = handler.add_page_break(&doc_id);
|
||||
assert!(result.is_ok());
|
||||
|
||||
handler.add_paragraph(&doc_id, "After page break", None).unwrap();
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("Before page break"));
|
||||
assert!(text.contains("After page break"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_text_empty_document() {
|
||||
let (handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
// Empty document might have some default content or be truly empty
|
||||
assert!(text.is_empty() || text.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_close_document() {
|
||||
let (mut handler, doc_id, temp_dir) = handler_and_doc();
|
||||
|
||||
handler.add_paragraph(&doc_id, "Test content", None).unwrap();
|
||||
|
||||
let save_path = temp_dir.path().join("test_output.docx");
|
||||
let save_result = handler.save_document(&doc_id, &save_path);
|
||||
assert!(save_result.is_ok());
|
||||
assert!(save_path.exists());
|
||||
|
||||
let close_result = handler.close_document(&doc_id);
|
||||
assert!(close_result.is_ok());
|
||||
assert!(!handler.documents.contains_key(&doc_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_existing_document() {
|
||||
let (mut handler, doc_id, temp_dir) = handler_and_doc();
|
||||
|
||||
// Create and save a document
|
||||
handler.add_paragraph(&doc_id, "Original content", None).unwrap();
|
||||
let save_path = temp_dir.path().join("existing.docx");
|
||||
handler.save_document(&doc_id, &save_path).unwrap();
|
||||
handler.close_document(&doc_id).unwrap();
|
||||
|
||||
// Open the saved document
|
||||
let opened_doc_id = handler.open_document(&save_path).unwrap();
|
||||
assert_ne!(opened_doc_id, doc_id); // Should be a new ID
|
||||
|
||||
let text = handler.extract_text(&opened_doc_id).unwrap();
|
||||
assert!(text.contains("Original content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_documents() {
|
||||
let (mut handler, _temp_dir) = setup_test_handler();
|
||||
|
||||
// Initially should be empty
|
||||
let docs = handler.list_documents();
|
||||
let initial_count = docs.len();
|
||||
|
||||
// Create some documents
|
||||
let _doc1 = handler.create_document().unwrap();
|
||||
let _doc2 = handler.create_document().unwrap();
|
||||
let _doc3 = handler.create_document().unwrap();
|
||||
|
||||
let docs = handler.list_documents();
|
||||
assert_eq!(docs.len(), initial_count + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_document_not_found_error() {
|
||||
let (handler, _temp_dir) = setup_test_handler();
|
||||
|
||||
let fake_id = "nonexistent-document-id";
|
||||
|
||||
let result = handler.extract_text(fake_id);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("Document not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_metadata() {
|
||||
let (handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id).unwrap();
|
||||
|
||||
assert_eq!(metadata.id, doc_id);
|
||||
assert!(metadata.path.exists());
|
||||
assert!(metadata.created_at <= Utc::now());
|
||||
assert!(metadata.modified_at <= Utc::now());
|
||||
assert_eq!(metadata.page_count, Some(1));
|
||||
assert_eq!(metadata.word_count, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrent_document_operations() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
|
||||
let (handler, _temp_dir) = setup_test_handler();
|
||||
let handler = Arc::new(Mutex::new(handler));
|
||||
|
||||
let handles: Vec<_> = (0..5).map(|i| {
|
||||
let handler = Arc::clone(&handler);
|
||||
thread::spawn(move || {
|
||||
let doc_id = {
|
||||
let mut h = handler.lock().unwrap();
|
||||
h.create_document().unwrap()
|
||||
};
|
||||
|
||||
{
|
||||
let mut h = handler.lock().unwrap();
|
||||
h.add_paragraph(&doc_id, &format!("Thread {} content", i), None).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
let h = handler.lock().unwrap();
|
||||
let text = h.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains(&format!("Thread {} content", i)));
|
||||
}
|
||||
|
||||
doc_id
|
||||
})
|
||||
}).collect();
|
||||
|
||||
let doc_ids: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||
|
||||
// All documents should be different
|
||||
let mut unique_ids = doc_ids.clone();
|
||||
unique_ids.sort();
|
||||
unique_ids.dedup();
|
||||
assert_eq!(unique_ids.len(), doc_ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_document_creation() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
// Add many paragraphs to test performance
|
||||
for i in 0..100 {
|
||||
let content = format!("Paragraph number {} with some content to make it realistic", i);
|
||||
handler.add_paragraph(&doc_id, &content, None).unwrap();
|
||||
}
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("Paragraph number 0"));
|
||||
assert!(text.contains("Paragraph number 99"));
|
||||
|
||||
// Verify word count
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
assert!(words.len() > 1000); // Should have many words
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_special_characters_in_content() {
|
||||
let (mut handler, doc_id, _temp_dir) = handler_and_doc();
|
||||
|
||||
let special_content = "Special chars: éñüñdéd, 中文, русский, العربية, 🚀📝✨";
|
||||
handler.add_paragraph(&doc_id, special_content, None).unwrap();
|
||||
|
||||
let text = handler.extract_text(&doc_id).unwrap();
|
||||
assert!(text.contains("éñüñdéd"));
|
||||
assert!(text.contains("🚀📝✨"));
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_tools::DocxToolsProvider;
|
||||
use docx_mcp::security::SecurityConfig;
|
||||
use mcp_core::{ToolProvider, ToolResult};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio_test;
|
||||
|
||||
/// Test complete document creation workflow from start to finish
|
||||
#[tokio::test]
|
||||
async fn test_complete_document_workflow() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
|
||||
// Step 1: Create a new document
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
value["document_id"].as_str().unwrap().to_string()
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to create document: {}", e),
|
||||
};
|
||||
|
||||
// Step 2: Add document structure
|
||||
let title_result = provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Annual Report 2024",
|
||||
"level": 1
|
||||
})).await;
|
||||
assert!(matches!(title_result, ToolResult::Success(_)));
|
||||
|
||||
// Step 3: Add introduction
|
||||
let intro_result = provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "This annual report provides a comprehensive overview of our company's performance, achievements, and strategic direction for the year 2024.",
|
||||
"style": {
|
||||
"font_size": 12,
|
||||
"alignment": "justify"
|
||||
}
|
||||
})).await;
|
||||
assert!(matches!(intro_result, ToolResult::Success(_)));
|
||||
|
||||
// Step 4: Add executive summary section
|
||||
let exec_heading_result = provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Executive Summary",
|
||||
"level": 2
|
||||
})).await;
|
||||
assert!(matches!(exec_heading_result, ToolResult::Success(_)));
|
||||
|
||||
let exec_content = provider.call_tool("add_list", json!({
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
"Record revenue growth of 15% year-over-year",
|
||||
"Successful expansion into three new markets",
|
||||
"Launch of five innovative products",
|
||||
"Achievement of carbon neutrality goals",
|
||||
"Increased employee satisfaction by 20%"
|
||||
],
|
||||
"ordered": false
|
||||
})).await;
|
||||
assert!(matches!(exec_content, ToolResult::Success(_)));
|
||||
|
||||
// Step 5: Add financial data table
|
||||
let financial_heading = provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Financial Highlights",
|
||||
"level": 2
|
||||
})).await;
|
||||
assert!(matches!(financial_heading, ToolResult::Success(_)));
|
||||
|
||||
let table_result = provider.call_tool("add_table", json!({
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Metric", "2023", "2024", "Change"],
|
||||
["Revenue ($M)", "120.5", "138.6", "+15%"],
|
||||
["Operating Income ($M)", "24.1", "29.3", "+22%"],
|
||||
["Net Income ($M)", "18.2", "22.7", "+25%"],
|
||||
["Employees", "1,250", "1,420", "+14%"]
|
||||
]
|
||||
})).await;
|
||||
assert!(matches!(table_result, ToolResult::Success(_)));
|
||||
|
||||
// Step 6: Add page break and new section
|
||||
let page_break_result = provider.call_tool("add_page_break", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
assert!(matches!(page_break_result, ToolResult::Success(_)));
|
||||
|
||||
let strategy_heading = provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Strategic Initiatives",
|
||||
"level": 2
|
||||
})).await;
|
||||
assert!(matches!(strategy_heading, ToolResult::Success(_)));
|
||||
|
||||
// Step 7: Add multiple paragraphs with different styles
|
||||
let bold_paragraph = provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Digital Transformation: Our commitment to digital innovation remains at the forefront of our strategic priorities.",
|
||||
"style": {
|
||||
"bold": true,
|
||||
"font_size": 13
|
||||
}
|
||||
})).await;
|
||||
assert!(matches!(bold_paragraph, ToolResult::Success(_)));
|
||||
|
||||
let regular_paragraph = provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Throughout 2024, we have invested significantly in technology infrastructure, data analytics capabilities, and employee digital skills development. This comprehensive approach has resulted in improved operational efficiency and enhanced customer experience across all touchpoints."
|
||||
})).await;
|
||||
assert!(matches!(regular_paragraph, ToolResult::Success(_)));
|
||||
|
||||
// Step 8: Set document header and footer
|
||||
let header_result = provider.call_tool("set_header", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Annual Report 2024 | Confidential"
|
||||
})).await;
|
||||
assert!(matches!(header_result, ToolResult::Success(_)));
|
||||
|
||||
let footer_result = provider.call_tool("set_footer", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "© 2024 Company Name. All rights reserved."
|
||||
})).await;
|
||||
assert!(matches!(footer_result, ToolResult::Success(_)));
|
||||
|
||||
// Step 9: Verify document content
|
||||
let extract_result = provider.call_tool("extract_text", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
|
||||
// Verify all content is present
|
||||
assert!(text.contains("Annual Report 2024"));
|
||||
assert!(text.contains("Executive Summary"));
|
||||
assert!(text.contains("Record revenue growth"));
|
||||
assert!(text.contains("Financial Highlights"));
|
||||
assert!(text.contains("Revenue ($M)"));
|
||||
assert!(text.contains("138.6"));
|
||||
assert!(text.contains("Strategic Initiatives"));
|
||||
assert!(text.contains("Digital Transformation"));
|
||||
|
||||
println!("Document contains {} characters of text", text.len());
|
||||
assert!(text.len() > 1000, "Document should have substantial content");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
|
||||
}
|
||||
|
||||
// Step 10: Get document metadata
|
||||
let metadata_result = provider.call_tool("get_metadata", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match metadata_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let metadata = &value["metadata"];
|
||||
assert_eq!(metadata["id"], doc_id);
|
||||
assert!(metadata["path"].is_string());
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to get metadata: {}", e),
|
||||
}
|
||||
|
||||
// Step 11: Export to different formats
|
||||
let output_dir = temp_dir.path().join("exports");
|
||||
fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// Export to PDF
|
||||
let pdf_path = output_dir.join("annual_report.pdf");
|
||||
let pdf_result = provider.call_tool("convert_to_pdf", json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": pdf_path.to_str().unwrap()
|
||||
})).await;
|
||||
assert!(matches!(pdf_result, ToolResult::Success(_)));
|
||||
assert!(pdf_path.exists());
|
||||
|
||||
// Export to markdown
|
||||
let md_path = output_dir.join("annual_report.md");
|
||||
let md_result = provider.call_tool("export_to_markdown", json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": md_path.to_str().unwrap()
|
||||
})).await;
|
||||
assert!(matches!(md_result, ToolResult::Success(_)));
|
||||
assert!(md_path.exists());
|
||||
|
||||
// Step 12: Save the original document
|
||||
let save_path = output_dir.join("annual_report.docx");
|
||||
let save_result = provider.call_tool("save_document", json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": save_path.to_str().unwrap()
|
||||
})).await;
|
||||
assert!(matches!(save_result, ToolResult::Success(_)));
|
||||
assert!(save_path.exists());
|
||||
|
||||
println!("Complete workflow test successful! Generated files:");
|
||||
println!("- PDF: {:?}", pdf_path);
|
||||
println!("- Markdown: {:?}", md_path);
|
||||
println!("- DOCX: {:?}", save_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test document editing and revision workflow
|
||||
#[tokio::test]
|
||||
async fn test_document_editing_workflow() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
|
||||
// Create initial document
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Add initial content
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Project Status Report",
|
||||
"level": 1
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Current project status and upcoming milestones."
|
||||
})).await;
|
||||
|
||||
// Add tasks list
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Current Tasks",
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_list", json!({
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
"Complete user interface design",
|
||||
"Implement backend API",
|
||||
"Write unit tests",
|
||||
"Deploy to staging environment"
|
||||
],
|
||||
"ordered": true
|
||||
})).await;
|
||||
|
||||
// Search for specific content
|
||||
let search_result = provider.call_tool("search_text", json!({
|
||||
"document_id": doc_id,
|
||||
"search_term": "backend",
|
||||
"case_sensitive": false
|
||||
})).await;
|
||||
|
||||
match search_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let matches = value["matches"].as_array().unwrap();
|
||||
assert!(!matches.is_empty());
|
||||
assert!(value["total_matches"].as_u64().unwrap() > 0);
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Search failed: {}", e),
|
||||
}
|
||||
|
||||
// Get word count before modifications
|
||||
let word_count_before = provider.call_tool("get_word_count", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
let initial_word_count = match word_count_before {
|
||||
ToolResult::Success(value) => {
|
||||
value["statistics"]["words"].as_u64().unwrap()
|
||||
},
|
||||
_ => panic!("Failed to get word count"),
|
||||
};
|
||||
|
||||
// Add more content (simulating document expansion)
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Completed Items",
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_table", json!({
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Task", "Completed Date", "Notes"],
|
||||
["Requirements gathering", "2024-01-15", "All stakeholders interviewed"],
|
||||
["Architecture design", "2024-01-22", "Approved by tech committee"],
|
||||
["Database schema", "2024-01-28", "Optimized for performance"]
|
||||
]
|
||||
})).await;
|
||||
|
||||
// Add risks section
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Identified Risks",
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "The following risks have been identified and mitigation strategies are in place:",
|
||||
"style": {
|
||||
"italic": true
|
||||
}
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_list", json!({
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
"Resource constraints may delay delivery",
|
||||
"Third-party API changes could impact integration",
|
||||
"Security requirements may require additional development time"
|
||||
],
|
||||
"ordered": false
|
||||
})).await;
|
||||
|
||||
// Get word count after modifications
|
||||
let word_count_after = provider.call_tool("get_word_count", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
let final_word_count = match word_count_after {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let stats = &value["statistics"];
|
||||
let words = stats["words"].as_u64().unwrap();
|
||||
let chars = stats["characters"].as_u64().unwrap();
|
||||
let sentences = stats["sentences"].as_u64().unwrap();
|
||||
|
||||
println!("Document statistics: {} words, {} characters, {} sentences",
|
||||
words, chars, sentences);
|
||||
|
||||
assert!(words > 0);
|
||||
assert!(chars > 0);
|
||||
assert!(sentences > 0);
|
||||
|
||||
words
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to get final word count: {}", e),
|
||||
};
|
||||
|
||||
// Verify document grew
|
||||
assert!(final_word_count > initial_word_count,
|
||||
"Document should have more words after additions: {} -> {}",
|
||||
initial_word_count, final_word_count);
|
||||
|
||||
// Perform find and replace operation
|
||||
let replace_result = provider.call_tool("find_and_replace", json!({
|
||||
"document_id": doc_id,
|
||||
"find_text": "backend",
|
||||
"replace_text": "server-side",
|
||||
"case_sensitive": false
|
||||
})).await;
|
||||
|
||||
match replace_result {
|
||||
ToolResult::Success(value) => {
|
||||
// Note: The actual implementation might return different result structure
|
||||
println!("Find and replace completed: {:?}", value);
|
||||
},
|
||||
ToolResult::Error(_) => {
|
||||
// This is acceptable as find_and_replace might not be fully implemented
|
||||
println!("Find and replace not fully implemented yet");
|
||||
}
|
||||
}
|
||||
|
||||
// Final verification
|
||||
let final_text = provider.call_tool("extract_text", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match final_text {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
|
||||
// Verify all sections are present
|
||||
assert!(text.contains("Project Status Report"));
|
||||
assert!(text.contains("Current Tasks"));
|
||||
assert!(text.contains("Completed Items"));
|
||||
assert!(text.contains("Identified Risks"));
|
||||
assert!(text.contains("Requirements gathering"));
|
||||
assert!(text.contains("Resource constraints"));
|
||||
|
||||
println!("Final document contains {} characters", text.len());
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to extract final text: {}", e),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test collaborative workflow with multiple document operations
|
||||
#[tokio::test]
|
||||
async fn test_collaborative_workflow() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
let mut document_ids = Vec::new();
|
||||
|
||||
// Simulate multiple team members creating documents
|
||||
let team_members = vec!["Alice", "Bob", "Charlie"];
|
||||
|
||||
for member in &team_members {
|
||||
// Each member creates a document
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document for {}", member),
|
||||
};
|
||||
|
||||
// Add member-specific content
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("{}'s Weekly Report", member),
|
||||
"level": 1
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("This week, {} focused on the following activities and achievements.", member)
|
||||
})).await;
|
||||
|
||||
// Add achievements
|
||||
let achievements = match member {
|
||||
&"Alice" => vec![
|
||||
"Completed user research interviews",
|
||||
"Created wireframes for new features",
|
||||
"Updated design system documentation"
|
||||
],
|
||||
&"Bob" => vec![
|
||||
"Implemented new API endpoints",
|
||||
"Optimized database queries",
|
||||
"Fixed critical security vulnerability"
|
||||
],
|
||||
&"Charlie" => vec![
|
||||
"Deployed version 2.1 to production",
|
||||
"Set up monitoring dashboards",
|
||||
"Conducted security audit"
|
||||
],
|
||||
_ => vec!["General tasks completed"],
|
||||
};
|
||||
|
||||
provider.call_tool("add_list", json!({
|
||||
"document_id": doc_id,
|
||||
"items": achievements,
|
||||
"ordered": false
|
||||
})).await;
|
||||
|
||||
// Add metrics table
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Key Metrics",
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
let metrics = match member {
|
||||
&"Alice" => vec![
|
||||
vec!["Interviews Conducted", "8"],
|
||||
vec!["Designs Created", "12"],
|
||||
vec!["User Stories", "15"]
|
||||
],
|
||||
&"Bob" => vec![
|
||||
vec!["Lines of Code", "2,450"],
|
||||
vec!["Tests Written", "23"],
|
||||
vec!["Bugs Fixed", "7"]
|
||||
],
|
||||
&"Charlie" => vec![
|
||||
vec!["Deployments", "3"],
|
||||
vec!["Issues Resolved", "11"],
|
||||
vec!["System Uptime", "99.9%"]
|
||||
],
|
||||
_ => vec![vec!["Tasks", "5"]],
|
||||
};
|
||||
|
||||
let mut table_rows = vec![vec!["Metric".to_string(), "Value".to_string()]];
|
||||
for metric in metrics {
|
||||
table_rows.push(metric.iter().map(|s| s.to_string()).collect());
|
||||
}
|
||||
|
||||
provider.call_tool("add_table", json!({
|
||||
"document_id": doc_id,
|
||||
"rows": table_rows
|
||||
})).await;
|
||||
|
||||
document_ids.push((member.to_string(), doc_id));
|
||||
}
|
||||
|
||||
// List all documents
|
||||
let list_result = provider.call_tool("list_documents", json!({})).await;
|
||||
match list_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let documents = value["documents"].as_array().unwrap();
|
||||
assert!(documents.len() >= 3, "Should have at least 3 documents");
|
||||
|
||||
println!("Found {} documents in the system", documents.len());
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
|
||||
}
|
||||
|
||||
// Generate a summary document combining all reports
|
||||
let summary_result = provider.call_tool("create_document", json!({})).await;
|
||||
let summary_id = match summary_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create summary document"),
|
||||
};
|
||||
|
||||
// Add summary header
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": summary_id,
|
||||
"text": "Team Weekly Summary Report",
|
||||
"level": 1
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": summary_id,
|
||||
"text": "This document summarizes the key activities and achievements from all team members this week."
|
||||
})).await;
|
||||
|
||||
// Add content from each team member's document
|
||||
for (member, doc_id) in &document_ids {
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": summary_id,
|
||||
"text": format!("{} Highlights", member),
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
// Extract text from member's document
|
||||
let extract_result = provider.call_tool("extract_text", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
|
||||
// Extract key points (simplified - would be more sophisticated in real implementation)
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let summary_text = if lines.len() > 10 {
|
||||
format!("Key activities include multiple achievements in their focus areas. Full details available in {}'s individual report.", member)
|
||||
} else {
|
||||
format!("Summary content from {}'s report.", member)
|
||||
};
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": summary_id,
|
||||
"text": summary_text
|
||||
})).await;
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
println!("Warning: Could not extract text from {}'s document: {}", member, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add team totals table
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": summary_id,
|
||||
"text": "Team Totals",
|
||||
"level": 2
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_table", json!({
|
||||
"document_id": summary_id,
|
||||
"rows": [
|
||||
["Team Member", "Documents Created", "Key Focus"],
|
||||
["Alice", "1", "Design & Research"],
|
||||
["Bob", "1", "Development & Security"],
|
||||
["Charlie", "1", "Operations & Deployment"],
|
||||
["Total", "3", "Full-stack delivery"]
|
||||
]
|
||||
})).await;
|
||||
|
||||
// Convert all documents to PDF for archival
|
||||
let archive_dir = temp_dir.path().join("weekly_archive");
|
||||
fs::create_dir_all(&archive_dir)?;
|
||||
|
||||
for (member, doc_id) in &document_ids {
|
||||
let pdf_path = archive_dir.join(format!("{}_weekly_report.pdf", member.to_lowercase()));
|
||||
provider.call_tool("convert_to_pdf", json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": pdf_path.to_str().unwrap()
|
||||
})).await;
|
||||
|
||||
if pdf_path.exists() {
|
||||
println!("Archived {}'s report to PDF", member);
|
||||
}
|
||||
}
|
||||
|
||||
// Archive summary document
|
||||
let summary_pdf = archive_dir.join("team_summary.pdf");
|
||||
provider.call_tool("convert_to_pdf", json!({
|
||||
"document_id": summary_id,
|
||||
"output_path": summary_pdf.to_str().unwrap()
|
||||
})).await;
|
||||
|
||||
// Verify all PDFs were created
|
||||
let pdf_count = fs::read_dir(&archive_dir)?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("pdf"))
|
||||
.count();
|
||||
|
||||
assert!(pdf_count >= 3, "Should have created at least 3 PDF files");
|
||||
println!("Successfully archived {} PDF documents", pdf_count);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test security-restricted workflow
|
||||
#[tokio::test]
|
||||
async fn test_security_restricted_workflow() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
// Create a restrictive security configuration
|
||||
let mut whitelist = HashSet::new();
|
||||
whitelist.insert("open_document".to_string());
|
||||
whitelist.insert("extract_text".to_string());
|
||||
whitelist.insert("get_metadata".to_string());
|
||||
whitelist.insert("search_text".to_string());
|
||||
whitelist.insert("get_word_count".to_string());
|
||||
whitelist.insert("list_documents".to_string());
|
||||
whitelist.insert("get_security_info".to_string());
|
||||
|
||||
let security_config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
command_whitelist: Some(whitelist),
|
||||
max_document_size: 1024 * 1024, // 1MB
|
||||
max_open_documents: 5,
|
||||
allow_external_tools: false,
|
||||
allow_network: false,
|
||||
};
|
||||
|
||||
let provider = DocxToolsProvider::new_with_security(security_config);
|
||||
|
||||
// Test security info
|
||||
let security_info = provider.call_tool("get_security_info", json!({})).await;
|
||||
match security_info {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let security = &value["security"];
|
||||
assert_eq!(security["readonly_mode"], true);
|
||||
assert_eq!(security["sandbox_mode"], true);
|
||||
println!("Security configuration: {}", security["summary"].as_str().unwrap());
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Failed to get security info: {}", e),
|
||||
}
|
||||
|
||||
// Test that write operations are blocked
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
match create_result {
|
||||
ToolResult::Success(value) => {
|
||||
// Should fail security check
|
||||
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
|
||||
println!("Create document correctly blocked: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Test that add_paragraph is blocked
|
||||
let paragraph_result = provider.call_tool("add_paragraph", json!({
|
||||
"document_id": "test",
|
||||
"text": "This should be blocked"
|
||||
})).await;
|
||||
|
||||
match paragraph_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
|
||||
println!("Add paragraph correctly blocked: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a test document externally (outside security restrictions)
|
||||
let unrestricted_provider = DocxToolsProvider::new();
|
||||
let create_result = unrestricted_provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create test document"),
|
||||
};
|
||||
|
||||
// Add content with unrestricted provider
|
||||
unrestricted_provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Security Test Document",
|
||||
"level": 1
|
||||
})).await;
|
||||
|
||||
unrestricted_provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "This document is used to test readonly access capabilities in a security-restricted environment."
|
||||
})).await;
|
||||
|
||||
unrestricted_provider.call_tool("add_list", json!({
|
||||
"document_id": doc_id,
|
||||
"items": [
|
||||
"Test text extraction",
|
||||
"Test search functionality",
|
||||
"Test metadata retrieval",
|
||||
"Test word counting"
|
||||
],
|
||||
"ordered": true
|
||||
})).await;
|
||||
|
||||
// Now test readonly operations with restricted provider
|
||||
// These should work because they're in the whitelist
|
||||
|
||||
// Test text extraction
|
||||
let extract_result = provider.call_tool("extract_text", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let text = value["text"].as_str().unwrap();
|
||||
assert!(text.contains("Security Test Document"));
|
||||
assert!(text.contains("Test text extraction"));
|
||||
println!("Text extraction successful: {} characters", text.len());
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Text extraction should work: {}", e),
|
||||
}
|
||||
|
||||
// Test search functionality
|
||||
let search_result = provider.call_tool("search_text", json!({
|
||||
"document_id": doc_id,
|
||||
"search_term": "security",
|
||||
"case_sensitive": false
|
||||
})).await;
|
||||
|
||||
match search_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
assert!(value["total_matches"].as_u64().unwrap() > 0);
|
||||
println!("Search successful: found {} matches", value["total_matches"]);
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Search should work: {}", e),
|
||||
}
|
||||
|
||||
// Test metadata retrieval
|
||||
let metadata_result = provider.call_tool("get_metadata", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match metadata_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let metadata = &value["metadata"];
|
||||
assert_eq!(metadata["id"], doc_id);
|
||||
println!("Metadata retrieval successful");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Metadata retrieval should work: {}", e),
|
||||
}
|
||||
|
||||
// Test word counting
|
||||
let word_count_result = provider.call_tool("get_word_count", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match word_count_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let stats = &value["statistics"];
|
||||
assert!(stats["words"].as_u64().unwrap() > 0);
|
||||
println!("Word count successful: {} words", stats["words"]);
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Word count should work: {}", e),
|
||||
}
|
||||
|
||||
// Test document listing
|
||||
let list_result = provider.call_tool("list_documents", json!({})).await;
|
||||
match list_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
println!("Document listing successful");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Document listing should work: {}", e),
|
||||
}
|
||||
|
||||
// Test that conversion operations are blocked (not in whitelist)
|
||||
let pdf_result = provider.call_tool("convert_to_pdf", json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": "/tmp/test.pdf"
|
||||
})).await;
|
||||
|
||||
match pdf_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Security check failed") || e.contains("Command not allowed"));
|
||||
println!("PDF conversion correctly blocked: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Security-restricted workflow test completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test error recovery workflow
|
||||
#[tokio::test]
|
||||
async fn test_error_recovery_workflow() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
|
||||
// Test recovery from invalid document ID
|
||||
let invalid_ops = vec![
|
||||
("extract_text", json!({"document_id": "nonexistent-123"})),
|
||||
("add_paragraph", json!({"document_id": "fake-456", "text": "test"})),
|
||||
("get_metadata", json!({"document_id": "invalid-789"})),
|
||||
("get_word_count", json!({"document_id": "missing-000"})),
|
||||
];
|
||||
|
||||
for (operation, args) in invalid_ops {
|
||||
let result = provider.call_tool(operation, args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
// Should indicate failure
|
||||
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
|
||||
assert!(value.get("error").is_some());
|
||||
println!("{} correctly handled invalid document ID", operation);
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Document not found") || e.contains("not found"));
|
||||
println!("{} correctly returned error for invalid document: {}", operation, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test recovery from invalid arguments
|
||||
let invalid_arg_ops = vec![
|
||||
("add_heading", json!({"document_id": "test", "level": 10})), // Invalid level
|
||||
("add_paragraph", json!({"text": "missing document_id"})), // Missing required field
|
||||
("add_table", json!({"document_id": "test", "rows": "not_an_array"})), // Wrong type
|
||||
];
|
||||
|
||||
for (operation, args) in invalid_arg_ops {
|
||||
let result = provider.call_tool(operation, args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap());
|
||||
println!("{} handled invalid arguments gracefully", operation);
|
||||
},
|
||||
ToolResult::Error(e) => {
|
||||
println!("{} returned error for invalid arguments: {}", operation, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test successful operation after errors
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
value["document_id"].as_str().unwrap().to_string()
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Should be able to create document after errors: {}", e),
|
||||
};
|
||||
|
||||
// Verify normal operations work after handling errors
|
||||
let paragraph_result = provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "This should work after error recovery"
|
||||
})).await;
|
||||
|
||||
match paragraph_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
println!("Normal operations work after error handling");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Normal operation should work after errors: {}", e),
|
||||
}
|
||||
|
||||
// Test that the document has the expected content
|
||||
let extract_result = provider.call_tool("extract_text", json!({
|
||||
"document_id": doc_id
|
||||
})).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
assert!(text.contains("This should work after error recovery"));
|
||||
println!("Error recovery workflow completed successfully");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Text extraction failed: {}", e),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Vendored
-457
@@ -1,457 +0,0 @@
|
||||
//! Test fixtures and helper data for the docx-mcp test suite
|
||||
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub mod sample_documents;
|
||||
pub mod test_data;
|
||||
|
||||
/// Common test fixture for creating a handler with a temporary directory
|
||||
pub fn create_test_handler() -> (DocxHandler, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
(handler, temp_dir)
|
||||
}
|
||||
|
||||
/// Create a handler with a document containing basic content
|
||||
pub fn create_handler_with_document() -> (DocxHandler, String, TempDir) {
|
||||
let (mut handler, temp_dir) = create_test_handler();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
(handler, doc_id, temp_dir)
|
||||
}
|
||||
|
||||
/// Standard document styles for testing
|
||||
pub struct TestStyles;
|
||||
|
||||
impl TestStyles {
|
||||
pub fn basic() -> DocxStyle {
|
||||
DocxStyle {
|
||||
font_family: Some("Calibri".to_string()),
|
||||
font_size: Some(11),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underline: Some(false),
|
||||
color: Some("#000000".to_string()),
|
||||
alignment: Some("left".to_string()),
|
||||
line_spacing: Some(1.15),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heading() -> DocxStyle {
|
||||
DocxStyle {
|
||||
font_family: Some("Calibri".to_string()),
|
||||
font_size: Some(16),
|
||||
bold: Some(true),
|
||||
italic: Some(false),
|
||||
underline: Some(false),
|
||||
color: Some("#1f4e79".to_string()),
|
||||
alignment: Some("left".to_string()),
|
||||
line_spacing: Some(1.15),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emphasis() -> DocxStyle {
|
||||
DocxStyle {
|
||||
font_family: Some("Calibri".to_string()),
|
||||
font_size: Some(11),
|
||||
bold: Some(true),
|
||||
italic: Some(true),
|
||||
underline: Some(false),
|
||||
color: Some("#c55a11".to_string()),
|
||||
alignment: Some("left".to_string()),
|
||||
line_spacing: Some(1.15),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn centered() -> DocxStyle {
|
||||
DocxStyle {
|
||||
font_family: Some("Calibri".to_string()),
|
||||
font_size: Some(11),
|
||||
bold: Some(false),
|
||||
italic: Some(false),
|
||||
underline: Some(false),
|
||||
color: Some("#000000".to_string()),
|
||||
alignment: Some("center".to_string()),
|
||||
line_spacing: Some(1.15),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard table data for testing
|
||||
pub struct TestTables;
|
||||
|
||||
impl TestTables {
|
||||
pub fn simple_2x2() -> TableData {
|
||||
TableData {
|
||||
rows: vec![
|
||||
vec!["Row 1 Col 1".to_string(), "Row 1 Col 2".to_string()],
|
||||
vec!["Row 2 Col 1".to_string(), "Row 2 Col 2".to_string()],
|
||||
],
|
||||
headers: None,
|
||||
border_style: Some("single".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_headers() -> TableData {
|
||||
TableData {
|
||||
rows: vec![
|
||||
vec!["Name".to_string(), "Age".to_string(), "City".to_string()],
|
||||
vec!["John".to_string(), "30".to_string(), "New York".to_string()],
|
||||
vec!["Jane".to_string(), "25".to_string(), "Los Angeles".to_string()],
|
||||
vec!["Bob".to_string(), "35".to_string(), "Chicago".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Name".to_string(), "Age".to_string(), "City".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn financial_data() -> TableData {
|
||||
TableData {
|
||||
rows: vec![
|
||||
vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()],
|
||||
vec!["Q1 2024".to_string(), "$1.2M".to_string(), "$240K".to_string(), "15%".to_string()],
|
||||
vec!["Q2 2024".to_string(), "$1.4M".to_string(), "$290K".to_string(), "18%".to_string()],
|
||||
vec!["Q3 2024".to_string(), "$1.6M".to_string(), "$340K".to_string(), "22%".to_string()],
|
||||
vec!["Q4 2024".to_string(), "$1.8M".to_string(), "$380K".to_string(), "25%".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Quarter".to_string(), "Revenue".to_string(), "Profit".to_string(), "Growth".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn large_table(rows: usize, cols: usize) -> TableData {
|
||||
let mut table_rows = Vec::new();
|
||||
|
||||
// Header row
|
||||
let header_row: Vec<String> = (0..cols)
|
||||
.map(|i| format!("Column {}", i + 1))
|
||||
.collect();
|
||||
table_rows.push(header_row.clone());
|
||||
|
||||
// Data rows
|
||||
for row in 0..rows {
|
||||
let data_row: Vec<String> = (0..cols)
|
||||
.map(|col| format!("R{}C{}", row + 1, col + 1))
|
||||
.collect();
|
||||
table_rows.push(data_row);
|
||||
}
|
||||
|
||||
TableData {
|
||||
rows: table_rows,
|
||||
headers: Some(header_row),
|
||||
border_style: Some("single".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard list data for testing
|
||||
pub struct TestLists;
|
||||
|
||||
impl TestLists {
|
||||
pub fn simple_bullets() -> Vec<String> {
|
||||
vec![
|
||||
"First bullet point".to_string(),
|
||||
"Second bullet point".to_string(),
|
||||
"Third bullet point".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn numbered_steps() -> Vec<String> {
|
||||
vec![
|
||||
"Open the application".to_string(),
|
||||
"Navigate to the settings menu".to_string(),
|
||||
"Select the desired configuration".to_string(),
|
||||
"Save your changes".to_string(),
|
||||
"Restart the application".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn features_list() -> Vec<String> {
|
||||
vec![
|
||||
"Advanced document editing capabilities".to_string(),
|
||||
"Real-time collaboration tools".to_string(),
|
||||
"Cloud synchronization".to_string(),
|
||||
"Version control and history tracking".to_string(),
|
||||
"Export to multiple formats (PDF, HTML, Markdown)".to_string(),
|
||||
"Template library with professional designs".to_string(),
|
||||
"Advanced formatting and styling options".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn technical_requirements() -> Vec<String> {
|
||||
vec![
|
||||
"Rust 1.70 or higher".to_string(),
|
||||
"Memory: 2GB RAM minimum, 4GB recommended".to_string(),
|
||||
"Storage: 500MB available space".to_string(),
|
||||
"Network: Internet connection for cloud features".to_string(),
|
||||
"OS: Windows 10, macOS 10.15, or Linux (Ubuntu 20.04+)".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn large_list(item_count: usize) -> Vec<String> {
|
||||
(1..=item_count)
|
||||
.map(|i| format!("List item number {} with descriptive content", i))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample text content for testing
|
||||
pub struct TestContent;
|
||||
|
||||
impl TestContent {
|
||||
pub fn lorem_ipsum() -> &'static str {
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
||||
}
|
||||
|
||||
pub fn technical_paragraph() -> &'static str {
|
||||
"This application leverages cutting-edge Rust technology to provide high-performance document processing capabilities. The architecture is built on modern asynchronous programming patterns, ensuring efficient resource utilization and scalability. Key features include memory-safe operations, zero-cost abstractions, and excellent concurrent processing performance."
|
||||
}
|
||||
|
||||
pub fn business_paragraph() -> &'static str {
|
||||
"Our comprehensive business solution addresses the evolving needs of modern enterprises through innovative technology and streamlined workflows. With a focus on productivity enhancement and cost reduction, this platform delivers measurable value across multiple departments and use cases. The solution integrates seamlessly with existing infrastructure while providing robust security and compliance features."
|
||||
}
|
||||
|
||||
pub fn multilingual_content() -> Vec<(&'static str, &'static str)> {
|
||||
vec![
|
||||
("English", "The quick brown fox jumps over the lazy dog."),
|
||||
("Spanish", "El zorro marrón rápido salta sobre el perro perezoso."),
|
||||
("French", "Le renard brun rapide saute par-dessus le chien paresseux."),
|
||||
("German", "Der schnelle braune Fuchs springt über den faulen Hund."),
|
||||
("Italian", "La volpe marrone veloce salta sopra il cane pigro."),
|
||||
("Portuguese", "A raposa marrom rápida pula sobre o cão preguiçoso."),
|
||||
("Japanese", "素早い茶色のキツネは怠惰な犬を飛び越える。"),
|
||||
("Chinese", "敏捷的棕色狐狸跳过懒狗。"),
|
||||
("Korean", "빠른 갈색 여우가 게으른 개를 뛰어넘는다."),
|
||||
("Russian", "Быстрая коричневая лиса прыгает через ленивую собаку."),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn special_characters() -> &'static str {
|
||||
"Special characters test: àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ ĀāĂ㥹ĆćĈĉĊċČčĎďĐđĒēĔĕĖėĘęĚě"
|
||||
}
|
||||
|
||||
pub fn symbols_and_math() -> &'static str {
|
||||
"Mathematical symbols: ∑ ∏ ∫ √ ≤ ≥ ≠ ± ∞ ∂ ∇ Ω α β γ δ ε θ λ μ π σ φ ψ ω"
|
||||
}
|
||||
|
||||
pub fn long_paragraph(sentence_count: usize) -> String {
|
||||
let sentences = vec![
|
||||
"This is a comprehensive test of document processing capabilities.",
|
||||
"The system handles various types of content efficiently and accurately.",
|
||||
"Performance optimization ensures smooth operation even with large documents.",
|
||||
"Advanced formatting features provide professional document appearance.",
|
||||
"Error handling mechanisms maintain system stability under all conditions.",
|
||||
"Security features protect sensitive information throughout the process.",
|
||||
"Integration capabilities allow seamless workflow with existing systems.",
|
||||
"User-friendly interfaces make complex operations simple and intuitive.",
|
||||
"Scalable architecture supports growing business requirements.",
|
||||
"Continuous improvements ensure the solution remains cutting-edge.",
|
||||
];
|
||||
|
||||
let mut result = String::new();
|
||||
for i in 0..sentence_count {
|
||||
let sentence = sentences[i % sentences.len()];
|
||||
result.push_str(sentence);
|
||||
if i < sentence_count - 1 {
|
||||
result.push(' ');
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP tool call arguments for testing
|
||||
pub struct TestMcpArgs;
|
||||
|
||||
impl TestMcpArgs {
|
||||
pub fn create_document() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
pub fn add_paragraph(doc_id: &str, text: &str, style: Option<DocxStyle>) -> Value {
|
||||
let mut args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": text
|
||||
});
|
||||
|
||||
if let Some(s) = style {
|
||||
args["style"] = json!({
|
||||
"font_family": s.font_family,
|
||||
"font_size": s.font_size,
|
||||
"bold": s.bold,
|
||||
"italic": s.italic,
|
||||
"underline": s.underline,
|
||||
"color": s.color,
|
||||
"alignment": s.alignment,
|
||||
"line_spacing": s.line_spacing
|
||||
});
|
||||
}
|
||||
|
||||
args
|
||||
}
|
||||
|
||||
pub fn add_heading(doc_id: &str, text: &str, level: usize) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"text": text,
|
||||
"level": level
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_table(doc_id: &str, table_data: &TableData) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"rows": table_data.rows
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_list(doc_id: &str, items: &[String], ordered: bool) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"items": items,
|
||||
"ordered": ordered
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_text(doc_id: &str) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id
|
||||
})
|
||||
}
|
||||
|
||||
pub fn search_text(doc_id: &str, search_term: &str, case_sensitive: bool) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"search_term": search_term,
|
||||
"case_sensitive": case_sensitive
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_metadata(doc_id: &str) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id
|
||||
})
|
||||
}
|
||||
|
||||
pub fn convert_to_pdf(doc_id: &str, output_path: &str) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": output_path
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_document(doc_id: &str, output_path: &str) -> Value {
|
||||
json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": output_path
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance test data generators
|
||||
pub struct PerformanceData;
|
||||
|
||||
impl PerformanceData {
|
||||
pub fn create_large_document(handler: &mut DocxHandler, paragraph_count: usize) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
handler.add_heading(&doc_id, "Performance Test Document", 1)?;
|
||||
|
||||
for i in 0..paragraph_count {
|
||||
if i % 50 == 0 && i > 0 {
|
||||
handler.add_heading(&doc_id, &format!("Section {}", i / 50), 2)?;
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"This is paragraph {} in our performance test document. It contains substantial text content to simulate real-world usage patterns and test system performance under realistic load conditions. The paragraph includes various punctuation marks, numbers like {}, and other elements that affect processing performance.",
|
||||
i + 1, (i + 1) * 7
|
||||
);
|
||||
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
|
||||
// Add tables periodically
|
||||
if i % 100 == 99 {
|
||||
let table_data = TestTables::simple_2x2();
|
||||
handler.add_table(&doc_id, table_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
pub fn create_complex_document(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Add comprehensive content with all features
|
||||
handler.add_heading(&doc_id, "Complex Document Test", 1)?;
|
||||
|
||||
handler.set_header(&doc_id, "Complex Document Header")?;
|
||||
handler.set_footer(&doc_id, "Complex Document Footer")?;
|
||||
|
||||
handler.add_paragraph(&doc_id, TestContent::business_paragraph(), Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_heading(&doc_id, "Technical Details", 2)?;
|
||||
handler.add_paragraph(&doc_id, TestContent::technical_paragraph(), None)?;
|
||||
|
||||
let features_list = TestLists::features_list();
|
||||
handler.add_list(&doc_id, features_list, false)?;
|
||||
|
||||
handler.add_heading(&doc_id, "Financial Overview", 2)?;
|
||||
let financial_table = TestTables::financial_data();
|
||||
handler.add_table(&doc_id, financial_table)?;
|
||||
|
||||
handler.add_page_break(&doc_id)?;
|
||||
|
||||
handler.add_heading(&doc_id, "Multilingual Content", 2)?;
|
||||
for (language, text) in TestContent::multilingual_content() {
|
||||
handler.add_paragraph(&doc_id, &format!("{}: {}", language, text), None)?;
|
||||
}
|
||||
|
||||
handler.add_heading(&doc_id, "Special Characters", 2)?;
|
||||
handler.add_paragraph(&doc_id, TestContent::special_characters(), None)?;
|
||||
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), None)?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error testing utilities
|
||||
pub struct ErrorTestCases;
|
||||
|
||||
impl ErrorTestCases {
|
||||
pub fn invalid_document_ids() -> Vec<&'static str> {
|
||||
vec![
|
||||
"nonexistent-123",
|
||||
"fake-document-id",
|
||||
"invalid-uuid",
|
||||
"",
|
||||
" ",
|
||||
"null",
|
||||
"undefined",
|
||||
]
|
||||
}
|
||||
|
||||
pub fn invalid_mcp_calls() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("add_paragraph", json!({"text": "missing document_id"})),
|
||||
("add_heading", json!({"document_id": "test", "level": 10})),
|
||||
("add_table", json!({"document_id": "test", "rows": "not_an_array"})),
|
||||
("add_list", json!({"document_id": "test", "items": 123})),
|
||||
("search_text", json!({"document_id": "test"})), // Missing search_term
|
||||
("convert_to_pdf", json!({"document_id": "test"})), // Missing output_path
|
||||
]
|
||||
}
|
||||
|
||||
pub fn security_blocked_operations() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("create_document", json!({})),
|
||||
("add_paragraph", json!({"document_id": "test", "text": "blocked"})),
|
||||
("save_document", json!({"document_id": "test", "output_path": "/tmp/test.docx"})),
|
||||
("convert_to_pdf", json!({"document_id": "test", "output_path": "/tmp/test.pdf"})),
|
||||
("find_and_replace", json!({"document_id": "test", "find_text": "a", "replace_text": "b"})),
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
-509
@@ -1,509 +0,0 @@
|
||||
//! Sample document templates and content for testing
|
||||
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use super::{TestStyles, TestTables, TestLists, TestContent};
|
||||
|
||||
/// Creates a business letter document for testing
|
||||
pub fn create_business_letter(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Header
|
||||
handler.set_header(&doc_id, "ACME Corporation | 123 Business St, City, State 12345")?;
|
||||
|
||||
// Date
|
||||
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
|
||||
// Recipient
|
||||
handler.add_paragraph(&doc_id, "Ms. Jane Smith", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "Director of Operations", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "XYZ Company", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "456 Corporate Ave", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "Business City, State 67890", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
|
||||
// Subject
|
||||
handler.add_paragraph(&doc_id, "RE: Partnership Proposal", Some(TestStyles::emphasis()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
|
||||
// Salutation
|
||||
handler.add_paragraph(&doc_id, "Dear Ms. Smith,", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
|
||||
// Body paragraphs
|
||||
handler.add_paragraph(&doc_id,
|
||||
"I am writing to propose a strategic partnership between ACME Corporation and XYZ Company that would benefit both organizations significantly. Our companies share similar values and complementary strengths that could create substantial value for our respective customers.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_paragraph(&doc_id,
|
||||
"ACME Corporation has been a leader in technology solutions for over 15 years, with a strong track record of innovation and customer satisfaction. We believe that combining our technical expertise with your operational excellence would create a powerful synergy in the marketplace.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_paragraph(&doc_id,
|
||||
"The proposed partnership would include joint product development, shared marketing initiatives, and coordinated customer support efforts. We estimate this collaboration could increase revenue for both companies by 25% within the first year.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_paragraph(&doc_id,
|
||||
"I would welcome the opportunity to discuss this proposal in more detail at your convenience. Please let me know when you might be available for a meeting or conference call.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
|
||||
// Closing
|
||||
handler.add_paragraph(&doc_id, "Sincerely,", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Space for signature
|
||||
handler.add_paragraph(&doc_id, "John Doe", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "Chief Executive Officer", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "ACME Corporation", Some(TestStyles::basic()))?;
|
||||
|
||||
// Footer
|
||||
handler.set_footer(&doc_id, "ACME Corporation - Confidential and Proprietary")?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
/// Creates a technical report document for testing
|
||||
pub fn create_technical_report(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Title page
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line for spacing
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
|
||||
handler.add_heading(&doc_id, "System Performance Analysis Report", 1)?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "Quarterly Assessment - Q4 2024", Some(TestStyles::centered()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "Prepared by: Technical Team", Some(TestStyles::centered()))?;
|
||||
handler.add_paragraph(&doc_id, "Date: December 15, 2024", Some(TestStyles::centered()))?;
|
||||
|
||||
handler.add_page_break(&doc_id)?;
|
||||
|
||||
// Executive Summary
|
||||
handler.add_heading(&doc_id, "Executive Summary", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"This report provides a comprehensive analysis of system performance metrics for Q4 2024. Key findings include significant improvements in response times, enhanced security measures, and successful implementation of new monitoring capabilities.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let summary_points = vec![
|
||||
"Average response time improved by 35%".to_string(),
|
||||
"System uptime achieved 99.97%".to_string(),
|
||||
"Security incidents reduced by 60%".to_string(),
|
||||
"User satisfaction increased to 94%".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, summary_points, false)?;
|
||||
|
||||
// Performance Metrics
|
||||
handler.add_heading(&doc_id, "Performance Metrics", 1)?;
|
||||
|
||||
handler.add_heading(&doc_id, "Response Time Analysis", 2)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Response time measurements were collected continuously throughout Q4 2024. The data shows consistent improvement across all service endpoints.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let response_time_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()],
|
||||
vec!["Authentication".to_string(), "245".to_string(), "158".to_string(), "35.5%".to_string()],
|
||||
vec!["Database Query".to_string(), "892".to_string(), "623".to_string(), "30.2%".to_string()],
|
||||
vec!["File Processing".to_string(), "1,240".to_string(), "789".to_string(), "36.4%".to_string()],
|
||||
vec!["Report Generation".to_string(), "3,450".to_string(), "2,180".to_string(), "36.8%".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Service".to_string(), "Q3 2024 (ms)".to_string(), "Q4 2024 (ms)".to_string(), "Improvement".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, response_time_data)?;
|
||||
|
||||
handler.add_heading(&doc_id, "System Reliability", 2)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"System reliability metrics demonstrate exceptional stability and availability throughout the quarter.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let reliability_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()],
|
||||
vec!["Uptime".to_string(), "99.9%".to_string(), "99.97%".to_string(), "✓ Exceeded".to_string()],
|
||||
vec!["MTBF (hours)".to_string(), "720".to_string(), "892".to_string(), "✓ Exceeded".to_string()],
|
||||
vec!["Recovery Time (min)".to_string(), "15".to_string(), "8.5".to_string(), "✓ Exceeded".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Metric".to_string(), "Target".to_string(), "Actual".to_string(), "Status".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, reliability_data)?;
|
||||
|
||||
// Security Analysis
|
||||
handler.add_heading(&doc_id, "Security Analysis", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Security monitoring and incident response capabilities were significantly enhanced during Q4 2024.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let security_improvements = vec![
|
||||
"Implemented advanced threat detection algorithms".to_string(),
|
||||
"Enhanced encryption protocols for data transmission".to_string(),
|
||||
"Deployed automated incident response systems".to_string(),
|
||||
"Conducted comprehensive security audits".to_string(),
|
||||
"Updated access control mechanisms".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, security_improvements, true)?;
|
||||
|
||||
// Recommendations
|
||||
handler.add_heading(&doc_id, "Recommendations", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Based on the analysis conducted, the following recommendations are proposed for Q1 2025:",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let recommendations = vec![
|
||||
"Continue performance optimization initiatives".to_string(),
|
||||
"Expand monitoring coverage to include new services".to_string(),
|
||||
"Implement predictive analytics for proactive maintenance".to_string(),
|
||||
"Enhance disaster recovery procedures".to_string(),
|
||||
"Invest in additional security training for staff".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, recommendations, true)?;
|
||||
|
||||
// Footer
|
||||
handler.set_footer(&doc_id, "Technical Report Q4 2024 - Confidential")?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
/// Creates a meeting minutes document for testing
|
||||
pub fn create_meeting_minutes(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Header
|
||||
handler.add_heading(&doc_id, "Project Steering Committee Meeting Minutes", 1)?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
|
||||
// Meeting details
|
||||
let meeting_details = TableData {
|
||||
rows: vec![
|
||||
vec!["Date:".to_string(), "December 15, 2024".to_string()],
|
||||
vec!["Time:".to_string(), "2:00 PM - 3:30 PM PST".to_string()],
|
||||
vec!["Location:".to_string(), "Conference Room A / Virtual".to_string()],
|
||||
vec!["Chair:".to_string(), "Sarah Johnson".to_string()],
|
||||
vec!["Secretary:".to_string(), "Mike Chen".to_string()],
|
||||
],
|
||||
headers: None,
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, meeting_details)?;
|
||||
|
||||
// Attendees
|
||||
handler.add_heading(&doc_id, "Attendees", 2)?;
|
||||
let attendees = vec![
|
||||
"Sarah Johnson (Chair) - Project Director".to_string(),
|
||||
"Mike Chen (Secretary) - Technical Lead".to_string(),
|
||||
"Lisa Wang - Product Manager".to_string(),
|
||||
"David Rodriguez - Engineering Manager".to_string(),
|
||||
"Jennifer Kim - QA Manager".to_string(),
|
||||
"Alex Thompson - DevOps Lead".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, attendees, false)?;
|
||||
|
||||
// Agenda Items
|
||||
handler.add_heading(&doc_id, "Agenda Items Discussed", 2)?;
|
||||
|
||||
handler.add_heading(&doc_id, "1. Project Status Update", 3)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Mike Chen presented the current project status, highlighting that development is 85% complete and on schedule for the January 31st deadline.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let status_highlights = vec![
|
||||
"Core functionality implementation: 100% complete".to_string(),
|
||||
"User interface development: 90% complete".to_string(),
|
||||
"Testing and QA: 70% complete".to_string(),
|
||||
"Documentation: 60% complete".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, status_highlights, false)?;
|
||||
|
||||
handler.add_heading(&doc_id, "2. Budget Review", 3)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Lisa Wang reported that the project is currently 5% under budget with strong cost controls in place.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let budget_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()],
|
||||
vec!["Development".to_string(), "$180,000".to_string(), "$168,000".to_string(), "$12,000".to_string()],
|
||||
vec!["Testing".to_string(), "$45,000".to_string(), "$38,000".to_string(), "$7,000".to_string()],
|
||||
vec!["Infrastructure".to_string(), "$30,000".to_string(), "$28,000".to_string(), "$2,000".to_string()],
|
||||
vec!["Total".to_string(), "$255,000".to_string(), "$234,000".to_string(), "$21,000".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Category".to_string(), "Budgeted".to_string(), "Actual".to_string(), "Remaining".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, budget_data)?;
|
||||
|
||||
handler.add_heading(&doc_id, "3. Risk Assessment", 3)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"David Rodriguez presented the updated risk register with mitigation strategies for identified risks.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let risks = vec![
|
||||
"Third-party API integration delays - Medium risk, mitigation plan in place".to_string(),
|
||||
"Resource availability during holidays - Low risk, backup resources identified".to_string(),
|
||||
"Performance requirements validation - Medium risk, load testing scheduled".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, risks, false)?;
|
||||
|
||||
// Action Items
|
||||
handler.add_heading(&doc_id, "Action Items", 2)?;
|
||||
|
||||
let action_items_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()],
|
||||
vec!["Complete load testing scenarios".to_string(), "Jennifer Kim".to_string(), "Dec 22, 2024".to_string(), "In Progress".to_string()],
|
||||
vec!["Finalize API integration testing".to_string(), "Mike Chen".to_string(), "Dec 20, 2024".to_string(), "Not Started".to_string()],
|
||||
vec!["Update project documentation".to_string(), "Lisa Wang".to_string(), "Jan 10, 2025".to_string(), "Not Started".to_string()],
|
||||
vec!["Prepare deployment checklist".to_string(), "Alex Thompson".to_string(), "Jan 15, 2025".to_string(), "Not Started".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Action Item".to_string(), "Owner".to_string(), "Due Date".to_string(), "Status".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, action_items_data)?;
|
||||
|
||||
// Next Meeting
|
||||
handler.add_heading(&doc_id, "Next Meeting", 2)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"The next steering committee meeting is scheduled for January 5, 2025, at 2:00 PM PST in Conference Room A.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
// Footer
|
||||
handler.set_footer(&doc_id, "Project Steering Committee - Meeting Minutes")?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
/// Creates a product specification document for testing
|
||||
pub fn create_product_spec(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Title page
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_heading(&doc_id, "Product Requirements Specification", 1)?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "Document Management System v2.0", Some(TestStyles::centered()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?;
|
||||
handler.add_paragraph(&doc_id, "Version 1.0", Some(TestStyles::centered()))?;
|
||||
handler.add_paragraph(&doc_id, "December 15, 2024", Some(TestStyles::centered()))?;
|
||||
|
||||
handler.add_page_break(&doc_id)?;
|
||||
|
||||
// Table of Contents (simplified)
|
||||
handler.add_heading(&doc_id, "Table of Contents", 1)?;
|
||||
let toc_items = vec![
|
||||
"1. Introduction".to_string(),
|
||||
"2. System Overview".to_string(),
|
||||
"3. Functional Requirements".to_string(),
|
||||
"4. Non-Functional Requirements".to_string(),
|
||||
"5. User Interface Requirements".to_string(),
|
||||
"6. System Architecture".to_string(),
|
||||
"7. Security Requirements".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, toc_items, true)?;
|
||||
|
||||
// Introduction
|
||||
handler.add_heading(&doc_id, "1. Introduction", 1)?;
|
||||
|
||||
handler.add_heading(&doc_id, "1.1 Purpose", 2)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"This document specifies the requirements for the Document Management System version 2.0. The system is designed to provide comprehensive document storage, retrieval, and collaboration capabilities for enterprise users.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
handler.add_heading(&doc_id, "1.2 Scope", 2)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"The Document Management System will support multiple file formats, version control, user collaboration, and advanced search capabilities. The system will be deployed as a web-based application with mobile support.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
// System Overview
|
||||
handler.add_heading(&doc_id, "2. System Overview", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"The Document Management System consists of several integrated components working together to provide a seamless document management experience.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let system_components = vec![
|
||||
"Document Storage Engine".to_string(),
|
||||
"Version Control System".to_string(),
|
||||
"Search and Indexing Service".to_string(),
|
||||
"User Authentication and Authorization".to_string(),
|
||||
"Collaboration Tools".to_string(),
|
||||
"Reporting and Analytics".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, system_components, false)?;
|
||||
|
||||
// Functional Requirements
|
||||
handler.add_heading(&doc_id, "3. Functional Requirements", 1)?;
|
||||
|
||||
handler.add_heading(&doc_id, "3.1 Document Upload and Storage", 2)?;
|
||||
let upload_requirements = vec![
|
||||
"FR-001: System shall support upload of files up to 100MB in size".to_string(),
|
||||
"FR-002: System shall support common file formats (PDF, DOCX, XLSX, PPTX, TXT)".to_string(),
|
||||
"FR-003: System shall automatically generate file metadata upon upload".to_string(),
|
||||
"FR-004: System shall provide drag-and-drop upload functionality".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, upload_requirements, false)?;
|
||||
|
||||
handler.add_heading(&doc_id, "3.2 Search and Retrieval", 2)?;
|
||||
let search_requirements = vec![
|
||||
"FR-005: System shall provide full-text search capabilities".to_string(),
|
||||
"FR-006: System shall support advanced search with multiple criteria".to_string(),
|
||||
"FR-007: System shall provide search result ranking and relevance scoring".to_string(),
|
||||
"FR-008: System shall support search within specific document types".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, search_requirements, false)?;
|
||||
|
||||
// Non-Functional Requirements
|
||||
handler.add_heading(&doc_id, "4. Non-Functional Requirements", 1)?;
|
||||
|
||||
let nfr_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()],
|
||||
vec!["Performance".to_string(), "Page load time < 3 seconds".to_string(), "High".to_string()],
|
||||
vec!["Scalability".to_string(), "Support 1000+ concurrent users".to_string(), "High".to_string()],
|
||||
vec!["Availability".to_string(), "99.9% uptime".to_string(), "High".to_string()],
|
||||
vec!["Security".to_string(), "Role-based access control".to_string(), "Critical".to_string()],
|
||||
vec!["Usability".to_string(), "Intuitive interface, minimal training".to_string(), "Medium".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Requirement".to_string(), "Specification".to_string(), "Priority".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, nfr_data)?;
|
||||
|
||||
// Security Requirements
|
||||
handler.add_heading(&doc_id, "7. Security Requirements", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"Security is paramount for the Document Management System. The following security measures must be implemented:",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
let security_requirements = vec![
|
||||
"SEC-001: All data transmission must use HTTPS/TLS 1.3".to_string(),
|
||||
"SEC-002: User passwords must meet complexity requirements".to_string(),
|
||||
"SEC-003: System must support multi-factor authentication".to_string(),
|
||||
"SEC-004: All user actions must be logged for audit purposes".to_string(),
|
||||
"SEC-005: Document access must be controlled by user permissions".to_string(),
|
||||
"SEC-006: System must support data encryption at rest".to_string(),
|
||||
];
|
||||
handler.add_list(&doc_id, security_requirements, true)?;
|
||||
|
||||
// Footer
|
||||
handler.set_footer(&doc_id, "Product Requirements Specification v1.0 - Confidential")?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
/// Creates a test document with international content
|
||||
pub fn create_multilingual_document(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
handler.add_heading(&doc_id, "Multilingual Content Test Document", 1)?;
|
||||
handler.add_paragraph(&doc_id,
|
||||
"This document contains text in multiple languages to test internationalization and Unicode support.",
|
||||
Some(TestStyles::basic()))?;
|
||||
|
||||
for (language, text) in TestContent::multilingual_content() {
|
||||
handler.add_heading(&doc_id, language, 2)?;
|
||||
handler.add_paragraph(&doc_id, text, Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "", None)?; // Empty line
|
||||
}
|
||||
|
||||
handler.add_heading(&doc_id, "Special Characters and Symbols", 2)?;
|
||||
handler.add_paragraph(&doc_id, TestContent::special_characters(), Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, TestContent::symbols_and_math(), Some(TestStyles::basic()))?;
|
||||
|
||||
// Currency symbols
|
||||
handler.add_paragraph(&doc_id, "Currency symbols: $ € £ ¥ ₹ ₽ ₩ ₪ ₫ ₱", Some(TestStyles::basic()))?;
|
||||
|
||||
// Emoji (if supported)
|
||||
handler.add_paragraph(&doc_id, "Emoji test: 📄 📝 💼 🔒 🌍 ✅ ❌ ⚠️", Some(TestStyles::basic()))?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
|
||||
/// Creates a document with complex formatting for testing
|
||||
pub fn create_formatted_document(handler: &mut DocxHandler) -> Result<String> {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
handler.add_heading(&doc_id, "Formatting Test Document", 1)?;
|
||||
|
||||
// Different paragraph styles
|
||||
handler.add_paragraph(&doc_id, "This paragraph uses the default style.", Some(TestStyles::basic()))?;
|
||||
handler.add_paragraph(&doc_id, "This paragraph uses bold formatting.", Some(DocxStyle {
|
||||
bold: Some(true),
|
||||
..TestStyles::basic()
|
||||
}))?;
|
||||
handler.add_paragraph(&doc_id, "This paragraph uses italic formatting.", Some(DocxStyle {
|
||||
italic: Some(true),
|
||||
..TestStyles::basic()
|
||||
}))?;
|
||||
handler.add_paragraph(&doc_id, "This paragraph is centered.", Some(TestStyles::centered()))?;
|
||||
handler.add_paragraph(&doc_id, "This paragraph uses emphasis styling.", Some(TestStyles::emphasis()))?;
|
||||
|
||||
// Different font sizes
|
||||
handler.add_heading(&doc_id, "Font Size Tests", 2)?;
|
||||
for size in [8, 10, 12, 14, 16, 18, 24] {
|
||||
let style = DocxStyle {
|
||||
font_size: Some(size),
|
||||
..TestStyles::basic()
|
||||
};
|
||||
handler.add_paragraph(&doc_id, &format!("This text is {} point size.", size), Some(style))?;
|
||||
}
|
||||
|
||||
// Color tests
|
||||
handler.add_heading(&doc_id, "Color Tests", 2)?;
|
||||
let colors = vec![
|
||||
("#000000", "Black"),
|
||||
("#FF0000", "Red"),
|
||||
("#00FF00", "Green"),
|
||||
("#0000FF", "Blue"),
|
||||
("#FF00FF", "Magenta"),
|
||||
("#00FFFF", "Cyan"),
|
||||
("#800080", "Purple"),
|
||||
];
|
||||
|
||||
for (color_code, color_name) in colors {
|
||||
let style = DocxStyle {
|
||||
color: Some(color_code.to_string()),
|
||||
..TestStyles::basic()
|
||||
};
|
||||
handler.add_paragraph(&doc_id, &format!("This text is in {}", color_name), Some(style))?;
|
||||
}
|
||||
|
||||
// Alignment tests
|
||||
handler.add_heading(&doc_id, "Alignment Tests", 2)?;
|
||||
let alignments = vec![
|
||||
("left", "Left aligned text"),
|
||||
("center", "Center aligned text"),
|
||||
("right", "Right aligned text"),
|
||||
("justify", "Justified text that should span the full width of the line when there is enough content to make it meaningful"),
|
||||
];
|
||||
|
||||
for (alignment, text) in alignments {
|
||||
let style = DocxStyle {
|
||||
alignment: Some(alignment.to_string()),
|
||||
..TestStyles::basic()
|
||||
};
|
||||
handler.add_paragraph(&doc_id, text, Some(style))?;
|
||||
}
|
||||
|
||||
// Complex table with formatting
|
||||
handler.add_heading(&doc_id, "Formatted Table", 2)?;
|
||||
let formatted_table = TableData {
|
||||
rows: vec![
|
||||
vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()],
|
||||
vec!["Widget A".to_string(), "$100.00".to_string(), "10%".to_string(), "$90.00".to_string()],
|
||||
vec!["Widget B".to_string(), "$150.00".to_string(), "15%".to_string(), "$127.50".to_string()],
|
||||
vec!["Widget C".to_string(), "$200.00".to_string(), "20%".to_string(), "$160.00".to_string()],
|
||||
vec!["Total".to_string(), "$450.00".to_string(), "".to_string(), "$377.50".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Item".to_string(), "Price".to_string(), "Discount".to_string(), "Final Price".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, formatted_table)?;
|
||||
|
||||
Ok(doc_id)
|
||||
}
|
||||
Vendored
-392
@@ -1,392 +0,0 @@
|
||||
//! Test data generators and utilities
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Generates test data for various document types and scenarios
|
||||
pub struct TestDataGenerator;
|
||||
|
||||
impl TestDataGenerator {
|
||||
/// Generate test paragraphs with varying complexity
|
||||
pub fn generate_paragraphs(count: usize, complexity: ParagraphComplexity) -> Vec<String> {
|
||||
let base_sentences = match complexity {
|
||||
ParagraphComplexity::Simple => vec![
|
||||
"This is a simple sentence.",
|
||||
"Another basic statement follows.",
|
||||
"The text remains straightforward.",
|
||||
"No complex structures here.",
|
||||
"Plain language is used throughout.",
|
||||
],
|
||||
ParagraphComplexity::Medium => vec![
|
||||
"This sentence demonstrates moderate complexity with additional clauses and descriptive elements.",
|
||||
"Furthermore, the content includes various punctuation marks, numbers like 123, and technical terms.",
|
||||
"The writing style incorporates both simple and compound sentence structures for variety.",
|
||||
"Additionally, references to specific dates (December 15, 2024) and percentages (85%) are included.",
|
||||
"These paragraphs simulate realistic document content found in business communications.",
|
||||
],
|
||||
ParagraphComplexity::Complex => vec![
|
||||
"This comprehensive sentence exemplifies sophisticated linguistic structures, incorporating multiple subordinate clauses, technical terminology, and complex syntactical arrangements that challenge both human readers and automated processing systems.",
|
||||
"Moreover, the content integrates diverse elements including numerical data (such as 42.7% improvement rates), temporal references (spanning Q3 2024 through Q1 2025), geographical locations (Silicon Valley, New York, London), and industry-specific jargon that reflects real-world document complexity.",
|
||||
"The methodology employed in generating these test paragraphs considers various factors: readability indices, sentence length distribution, vocabulary diversity, and the inclusion of special characters (e.g., àáâãäå, €£¥, ∑∏∫) to ensure comprehensive testing coverage.",
|
||||
"Consequently, these multi-faceted paragraphs serve as effective benchmarks for evaluating system performance under realistic conditions, while simultaneously providing sufficient content variation to identify potential edge cases and optimization opportunities.",
|
||||
],
|
||||
};
|
||||
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let sentence_count = match complexity {
|
||||
ParagraphComplexity::Simple => 2 + (i % 3),
|
||||
ParagraphComplexity::Medium => 3 + (i % 4),
|
||||
ParagraphComplexity::Complex => 2 + (i % 3),
|
||||
};
|
||||
|
||||
let mut paragraph = String::new();
|
||||
for j in 0..sentence_count {
|
||||
let sentence = &base_sentences[j % base_sentences.len()];
|
||||
if j > 0 {
|
||||
paragraph.push(' ');
|
||||
}
|
||||
paragraph.push_str(sentence);
|
||||
}
|
||||
|
||||
paragraph
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate table data with specified dimensions and content type
|
||||
pub fn generate_table_data(rows: usize, cols: usize, content_type: TableContentType) -> Vec<Vec<String>> {
|
||||
let mut table_data = Vec::new();
|
||||
|
||||
// Generate header row
|
||||
let headers: Vec<String> = (0..cols)
|
||||
.map(|i| match content_type {
|
||||
TableContentType::Generic => format!("Column {}", i + 1),
|
||||
TableContentType::Financial => match i {
|
||||
0 => "Period".to_string(),
|
||||
1 => "Revenue".to_string(),
|
||||
2 => "Expenses".to_string(),
|
||||
3 => "Profit".to_string(),
|
||||
_ => format!("Metric {}", i + 1),
|
||||
},
|
||||
TableContentType::Personnel => match i {
|
||||
0 => "Name".to_string(),
|
||||
1 => "Department".to_string(),
|
||||
2 => "Role".to_string(),
|
||||
3 => "Start Date".to_string(),
|
||||
_ => format!("Field {}", i + 1),
|
||||
},
|
||||
TableContentType::Technical => match i {
|
||||
0 => "Component".to_string(),
|
||||
1 => "Version".to_string(),
|
||||
2 => "Status".to_string(),
|
||||
3 => "Last Updated".to_string(),
|
||||
_ => format!("Attribute {}", i + 1),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
table_data.push(headers);
|
||||
|
||||
// Generate data rows
|
||||
for row in 0..rows {
|
||||
let row_data: Vec<String> = (0..cols)
|
||||
.map(|col| match content_type {
|
||||
TableContentType::Generic => format!("R{}C{}", row + 1, col + 1),
|
||||
TableContentType::Financial => match col {
|
||||
0 => format!("Q{} 2024", (row % 4) + 1),
|
||||
1 => format!("${:.1}M", 100.0 + row as f64 * 12.5),
|
||||
2 => format!("${:.1}M", 70.0 + row as f64 * 8.2),
|
||||
3 => format!("${:.1}M", 30.0 + row as f64 * 4.3),
|
||||
_ => format!("{:.1}%", 15.0 + row as f64 * 2.1),
|
||||
},
|
||||
TableContentType::Personnel => match col {
|
||||
0 => format!("Employee {}", row + 1),
|
||||
1 => ["Engineering", "Sales", "Marketing", "Operations"][(row % 4)].to_string(),
|
||||
2 => ["Manager", "Developer", "Analyst", "Specialist"][(row % 4)].to_string(),
|
||||
3 => format!("2024-{:02}-{:02}", ((row % 12) + 1), ((row % 28) + 1)),
|
||||
_ => format!("Data {}", row + 1),
|
||||
},
|
||||
TableContentType::Technical => match col {
|
||||
0 => format!("Component-{}", row + 1),
|
||||
1 => format!("v{}.{}.{}", (row % 3) + 1, (row % 5), (row % 10)),
|
||||
2 => ["Active", "Pending", "Deprecated", "Testing"][(row % 4)].to_string(),
|
||||
3 => format!("2024-12-{:02}", ((row % 28) + 1)),
|
||||
_ => format!("Value {}", row + 1),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
table_data.push(row_data);
|
||||
}
|
||||
|
||||
table_data
|
||||
}
|
||||
|
||||
/// Generate list items with specified count and category
|
||||
pub fn generate_list_items(count: usize, category: ListCategory) -> Vec<String> {
|
||||
let base_items = match category {
|
||||
ListCategory::Tasks => vec![
|
||||
"Complete project documentation",
|
||||
"Review code changes and pull requests",
|
||||
"Update system configuration files",
|
||||
"Run comprehensive test suite",
|
||||
"Deploy to staging environment",
|
||||
"Conduct security audit",
|
||||
"Optimize database performance",
|
||||
"Update user interface components",
|
||||
"Implement new feature requirements",
|
||||
"Fix reported bugs and issues",
|
||||
],
|
||||
ListCategory::Features => vec![
|
||||
"Advanced search and filtering capabilities",
|
||||
"Real-time collaboration tools",
|
||||
"Automated backup and recovery",
|
||||
"Multi-language support",
|
||||
"Mobile-responsive design",
|
||||
"Integration with third-party services",
|
||||
"Customizable dashboard and reports",
|
||||
"Role-based access control",
|
||||
"API for external integrations",
|
||||
"Advanced analytics and insights",
|
||||
],
|
||||
ListCategory::Requirements => vec![
|
||||
"System must support 1000+ concurrent users",
|
||||
"Response time must be under 200ms for 95% of requests",
|
||||
"Uptime must exceed 99.9% availability",
|
||||
"Data must be encrypted both in transit and at rest",
|
||||
"User interface must be accessible (WCAG 2.1 AA)",
|
||||
"System must support multi-factor authentication",
|
||||
"Backup processes must complete within 2 hours",
|
||||
"Security patches must be applied within 24 hours",
|
||||
"System must scale horizontally to handle peak loads",
|
||||
"Audit logs must be maintained for minimum 7 years",
|
||||
],
|
||||
ListCategory::Benefits => vec![
|
||||
"Increased operational efficiency by 35%",
|
||||
"Reduced manual processing time by 60%",
|
||||
"Improved data accuracy and consistency",
|
||||
"Enhanced security and compliance posture",
|
||||
"Better user experience and satisfaction",
|
||||
"Lower total cost of ownership",
|
||||
"Faster time-to-market for new features",
|
||||
"Improved scalability and performance",
|
||||
"Better decision-making through analytics",
|
||||
"Reduced maintenance and support costs",
|
||||
],
|
||||
};
|
||||
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let base_item = &base_items[i % base_items.len()];
|
||||
if count > base_items.len() {
|
||||
format!("{} (item {})", base_item, i + 1)
|
||||
} else {
|
||||
base_item.clone()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate realistic business data for testing
|
||||
pub fn generate_business_data() -> BusinessDataSet {
|
||||
BusinessDataSet {
|
||||
companies: vec![
|
||||
"Acme Corporation".to_string(),
|
||||
"Global Tech Solutions".to_string(),
|
||||
"Innovation Partners LLC".to_string(),
|
||||
"Digital Dynamics Inc".to_string(),
|
||||
"Future Systems Ltd".to_string(),
|
||||
],
|
||||
departments: vec![
|
||||
"Engineering".to_string(),
|
||||
"Sales & Marketing".to_string(),
|
||||
"Human Resources".to_string(),
|
||||
"Operations".to_string(),
|
||||
"Finance & Accounting".to_string(),
|
||||
"Research & Development".to_string(),
|
||||
],
|
||||
positions: vec![
|
||||
"Software Engineer".to_string(),
|
||||
"Product Manager".to_string(),
|
||||
"Sales Representative".to_string(),
|
||||
"Data Analyst".to_string(),
|
||||
"Project Manager".to_string(),
|
||||
"UX Designer".to_string(),
|
||||
],
|
||||
locations: vec![
|
||||
"San Francisco, CA".to_string(),
|
||||
"New York, NY".to_string(),
|
||||
"Austin, TX".to_string(),
|
||||
"Seattle, WA".to_string(),
|
||||
"Boston, MA".to_string(),
|
||||
"Chicago, IL".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate MCP tool call test data
|
||||
pub fn generate_mcp_test_calls() -> Vec<McpTestCall> {
|
||||
vec![
|
||||
McpTestCall {
|
||||
tool_name: "create_document".to_string(),
|
||||
args: json!({}),
|
||||
expected_success: true,
|
||||
expected_result_keys: vec!["success".to_string(), "document_id".to_string()],
|
||||
},
|
||||
McpTestCall {
|
||||
tool_name: "add_paragraph".to_string(),
|
||||
args: json!({
|
||||
"document_id": "test-doc-id",
|
||||
"text": "Test paragraph content"
|
||||
}),
|
||||
expected_success: true,
|
||||
expected_result_keys: vec!["success".to_string()],
|
||||
},
|
||||
McpTestCall {
|
||||
tool_name: "add_heading".to_string(),
|
||||
args: json!({
|
||||
"document_id": "test-doc-id",
|
||||
"text": "Test Heading",
|
||||
"level": 1
|
||||
}),
|
||||
expected_success: true,
|
||||
expected_result_keys: vec!["success".to_string()],
|
||||
},
|
||||
McpTestCall {
|
||||
tool_name: "extract_text".to_string(),
|
||||
args: json!({
|
||||
"document_id": "test-doc-id"
|
||||
}),
|
||||
expected_success: true,
|
||||
expected_result_keys: vec!["success".to_string(), "text".to_string()],
|
||||
},
|
||||
McpTestCall {
|
||||
tool_name: "get_metadata".to_string(),
|
||||
args: json!({
|
||||
"document_id": "test-doc-id"
|
||||
}),
|
||||
expected_success: true,
|
||||
expected_result_keys: vec!["success".to_string(), "metadata".to_string()],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Generate performance test scenarios
|
||||
pub fn generate_performance_scenarios() -> Vec<PerformanceScenario> {
|
||||
vec![
|
||||
PerformanceScenario {
|
||||
name: "Small Document".to_string(),
|
||||
paragraph_count: 10,
|
||||
table_count: 1,
|
||||
list_count: 2,
|
||||
expected_max_time_ms: 1000,
|
||||
},
|
||||
PerformanceScenario {
|
||||
name: "Medium Document".to_string(),
|
||||
paragraph_count: 100,
|
||||
table_count: 5,
|
||||
list_count: 10,
|
||||
expected_max_time_ms: 5000,
|
||||
},
|
||||
PerformanceScenario {
|
||||
name: "Large Document".to_string(),
|
||||
paragraph_count: 500,
|
||||
table_count: 20,
|
||||
list_count: 30,
|
||||
expected_max_time_ms: 15000,
|
||||
},
|
||||
PerformanceScenario {
|
||||
name: "Extra Large Document".to_string(),
|
||||
paragraph_count: 1000,
|
||||
table_count: 50,
|
||||
list_count: 50,
|
||||
expected_max_time_ms: 30000,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Complexity levels for generated paragraphs
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParagraphComplexity {
|
||||
Simple,
|
||||
Medium,
|
||||
Complex,
|
||||
}
|
||||
|
||||
/// Content types for generated tables
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TableContentType {
|
||||
Generic,
|
||||
Financial,
|
||||
Personnel,
|
||||
Technical,
|
||||
}
|
||||
|
||||
/// Categories for generated lists
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ListCategory {
|
||||
Tasks,
|
||||
Features,
|
||||
Requirements,
|
||||
Benefits,
|
||||
}
|
||||
|
||||
/// Business data set for realistic testing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BusinessDataSet {
|
||||
pub companies: Vec<String>,
|
||||
pub departments: Vec<String>,
|
||||
pub positions: Vec<String>,
|
||||
pub locations: Vec<String>,
|
||||
}
|
||||
|
||||
/// MCP tool call test data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpTestCall {
|
||||
pub tool_name: String,
|
||||
pub args: Value,
|
||||
pub expected_success: bool,
|
||||
pub expected_result_keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Performance test scenario data
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PerformanceScenario {
|
||||
pub name: String,
|
||||
pub paragraph_count: usize,
|
||||
pub table_count: usize,
|
||||
pub list_count: usize,
|
||||
pub expected_max_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Utility functions for test data validation
|
||||
pub struct TestDataValidator;
|
||||
|
||||
impl TestDataValidator {
|
||||
/// Validate that text contains expected content
|
||||
pub fn validate_text_content(text: &str, expected_keywords: &[&str]) -> bool {
|
||||
expected_keywords.iter().all(|keyword| text.contains(keyword))
|
||||
}
|
||||
|
||||
/// Validate table structure
|
||||
pub fn validate_table_structure(rows: &[Vec<String>], expected_cols: usize) -> bool {
|
||||
!rows.is_empty() && rows.iter().all(|row| row.len() == expected_cols)
|
||||
}
|
||||
|
||||
/// Validate MCP response structure
|
||||
pub fn validate_mcp_response(response: &Value, expected_keys: &[String]) -> bool {
|
||||
expected_keys.iter().all(|key| response.get(key).is_some())
|
||||
}
|
||||
|
||||
/// Generate hash for test data consistency checking
|
||||
pub fn generate_content_hash(content: &str) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
content.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
use docx_mcp::docx_tools::DocxToolsProvider;
|
||||
use docx_mcp::security::SecurityConfig;
|
||||
use mcp_core::{ToolProvider, ToolResult};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio_test;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rstest::*;
|
||||
|
||||
async fn create_test_provider() -> (DocxToolsProvider, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
(provider, temp_dir)
|
||||
}
|
||||
|
||||
async fn create_test_provider_with_security(config: SecurityConfig) -> (DocxToolsProvider, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new_with_security(config);
|
||||
(provider, temp_dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tools_default_config() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let tools = provider.list_tools().await;
|
||||
|
||||
// Should have all tools in default configuration
|
||||
assert!(tools.len() > 20);
|
||||
|
||||
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
|
||||
assert!(tool_names.contains(&&"create_document".to_string()));
|
||||
assert!(tool_names.contains(&&"add_paragraph".to_string()));
|
||||
assert!(tool_names.contains(&&"convert_to_pdf".to_string()));
|
||||
assert!(tool_names.contains(&&"extract_text".to_string()));
|
||||
assert!(tool_names.contains(&&"get_security_info".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_tools_readonly_config() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
|
||||
|
||||
let tools = provider.list_tools().await;
|
||||
let tool_names: Vec<_> = tools.iter().map(|t| &t.name).collect();
|
||||
|
||||
// Should include readonly tools
|
||||
assert!(tool_names.contains(&&"extract_text".to_string()));
|
||||
assert!(tool_names.contains(&&"get_metadata".to_string()));
|
||||
assert!(tool_names.contains(&&"search_text".to_string()));
|
||||
|
||||
// Should not include write tools
|
||||
assert!(!tool_names.contains(&&"create_document".to_string()));
|
||||
assert!(!tool_names.contains(&&"add_paragraph".to_string()));
|
||||
assert!(!tool_names.contains(&&"save_document".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_document_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let result = provider.call_tool("create_document", json!({})).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
assert!(value["document_id"].is_string());
|
||||
let doc_id = value["document_id"].as_str().unwrap();
|
||||
assert!(!doc_id.is_empty());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_paragraph_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
// First create a document
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Add paragraph
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Test paragraph content"
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_paragraph", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
|
||||
// Verify content was added
|
||||
let extract_args = json!({"document_id": doc_id});
|
||||
let extract_result = provider.call_tool("extract_text", extract_args).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
assert!(text.contains("Test paragraph content"));
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_paragraph_with_style() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Styled paragraph",
|
||||
"style": {
|
||||
"font_size": 16,
|
||||
"bold": true,
|
||||
"color": "#FF0000",
|
||||
"alignment": "center"
|
||||
}
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_paragraph", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_table_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Name", "Age", "City"],
|
||||
["Alice", "30", "New York"],
|
||||
["Bob", "25", "Los Angeles"]
|
||||
]
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_table", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
|
||||
// Verify table content
|
||||
let extract_args = json!({"document_id": doc_id});
|
||||
let extract_result = provider.call_tool("extract_text", extract_args).await;
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
assert!(text.contains("Alice"));
|
||||
assert!(text.contains("New York"));
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Failed to extract text: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_heading_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Test different heading levels
|
||||
for level in 1..=6 {
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("Heading Level {}", level),
|
||||
"level": level
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_heading", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success for level {}, got error: {}", level, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_list_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Test ordered list
|
||||
let ordered_args = json!({
|
||||
"document_id": doc_id,
|
||||
"items": ["First item", "Second item", "Third item"],
|
||||
"ordered": true
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_list", ordered_args).await;
|
||||
assert!(matches!(result, ToolResult::Success(_)));
|
||||
|
||||
// Test unordered list
|
||||
let unordered_args = json!({
|
||||
"document_id": doc_id,
|
||||
"items": ["Bullet one", "Bullet two", "Bullet three"],
|
||||
"ordered": false
|
||||
});
|
||||
|
||||
let result = provider.call_tool("add_list", unordered_args).await;
|
||||
assert!(matches!(result, ToolResult::Success(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_metadata_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
let args = json!({"document_id": doc_id});
|
||||
let result = provider.call_tool("get_metadata", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let metadata = &value["metadata"];
|
||||
assert_eq!(metadata["id"], doc_id);
|
||||
assert!(metadata["path"].is_string());
|
||||
assert!(metadata["created_at"].is_string());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_text_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Add some content to search
|
||||
let add_args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": "This is a test document with searchable content. The word test appears multiple times."
|
||||
});
|
||||
provider.call_tool("add_paragraph", add_args).await;
|
||||
|
||||
// Search for text
|
||||
let search_args = json!({
|
||||
"document_id": doc_id,
|
||||
"search_term": "test",
|
||||
"case_sensitive": false
|
||||
});
|
||||
|
||||
let result = provider.call_tool("search_text", search_args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let matches = value["matches"].as_array().unwrap();
|
||||
assert!(matches.len() > 0);
|
||||
assert!(value["total_matches"].as_u64().unwrap() > 0);
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_word_count_tool() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Add content with known word count
|
||||
let content = "This sentence has exactly five words. This is another sentence with seven words total.";
|
||||
let add_args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": content
|
||||
});
|
||||
provider.call_tool("add_paragraph", add_args).await;
|
||||
|
||||
let args = json!({"document_id": doc_id});
|
||||
let result = provider.call_tool("get_word_count", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let stats = &value["statistics"];
|
||||
assert!(stats["words"].as_u64().unwrap() > 10);
|
||||
assert!(stats["characters"].as_u64().unwrap() > 0);
|
||||
assert!(stats["sentences"].as_u64().unwrap() >= 2);
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_security_info_tool() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
|
||||
|
||||
let result = provider.call_tool("get_security_info", json!({})).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let security = &value["security"];
|
||||
assert_eq!(security["readonly_mode"], true);
|
||||
assert_eq!(security["sandbox_mode"], true);
|
||||
assert!(security["summary"].is_string());
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_readonly_mode_blocks_write_operations() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let (provider, _temp_dir) = create_test_provider_with_security(config).await;
|
||||
|
||||
// Should fail to create document in readonly mode
|
||||
let result = provider.call_tool("create_document", json!({})).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Security check failed"));
|
||||
assert!(e.contains("Command not allowed"));
|
||||
}
|
||||
ToolResult::Success(_) => panic!("Expected security error, got success"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_document_not_found_error() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let args = json!({"document_id": "nonexistent-doc-id"});
|
||||
let result = provider.call_tool("extract_text", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(!value["success"].as_bool().unwrap());
|
||||
assert!(value["error"].as_str().unwrap().contains("Document not found"));
|
||||
}
|
||||
ToolResult::Error(_) => {
|
||||
// This is also acceptable - depends on implementation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_tool_name() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let result = provider.call_tool("nonexistent_tool", json!({})).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(!value["success"].as_bool().unwrap());
|
||||
assert!(value["error"].as_str().unwrap().contains("Unknown tool"));
|
||||
}
|
||||
ToolResult::Error(e) => {
|
||||
assert!(e.contains("Unknown tool"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_documents() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let mut doc_ids = Vec::new();
|
||||
|
||||
// Create multiple documents
|
||||
for i in 0..3 {
|
||||
let result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document {}", i),
|
||||
};
|
||||
|
||||
// Add unique content to each
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("Document {} content", i)
|
||||
});
|
||||
provider.call_tool("add_paragraph", args).await;
|
||||
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
|
||||
// List documents
|
||||
let list_result = provider.call_tool("list_documents", json!({})).await;
|
||||
|
||||
match list_result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
let documents = value["documents"].as_array().unwrap();
|
||||
assert!(documents.len() >= 3);
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Failed to list documents: {}", e),
|
||||
}
|
||||
|
||||
// Verify each document has its unique content
|
||||
for (i, doc_id) in doc_ids.iter().enumerate() {
|
||||
let args = json!({"document_id": doc_id});
|
||||
let result = provider.call_tool("extract_text", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
assert!(text.contains(&format!("Document {} content", i)));
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Failed to extract text from document {}: {}", i, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_to_markdown() {
|
||||
let (provider, temp_dir) = create_test_provider().await;
|
||||
|
||||
let create_result = provider.call_tool("create_document", json!({})).await;
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Add content
|
||||
provider.call_tool("add_heading", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "Test Document",
|
||||
"level": 1
|
||||
})).await;
|
||||
|
||||
provider.call_tool("add_paragraph", json!({
|
||||
"document_id": doc_id,
|
||||
"text": "This is a test paragraph."
|
||||
})).await;
|
||||
|
||||
// Export to markdown
|
||||
let output_path = temp_dir.path().join("test_export.md");
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"output_path": output_path.to_str().unwrap()
|
||||
});
|
||||
|
||||
let result = provider.call_tool("export_to_markdown", args).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap());
|
||||
assert!(output_path.exists());
|
||||
|
||||
let content = std::fs::read_to_string(&output_path).unwrap();
|
||||
assert!(content.contains("# Test Document"));
|
||||
assert!(content.contains("test paragraph"));
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Expected success, got error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
// Parametrized test using rstest
|
||||
#[rstest]
|
||||
#[case("create_document", json!({}))]
|
||||
#[case("list_documents", json!({}))]
|
||||
#[case("get_security_info", json!({}))]
|
||||
#[tokio::test]
|
||||
async fn test_tools_without_document_id(#[case] tool_name: &str, #[case] args: serde_json::Value) {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
let result = provider.call_tool(tool_name, args).await;
|
||||
|
||||
// These tools should work without requiring a document_id
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
assert!(value["success"].as_bool().unwrap_or(false));
|
||||
}
|
||||
ToolResult::Error(e) => panic!("Tool {} failed: {}", tool_name, e),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_input_validation() {
|
||||
let (provider, _temp_dir) = create_test_provider().await;
|
||||
|
||||
// Missing required arguments should fail gracefully
|
||||
let result = provider.call_tool("add_paragraph", json!({})).await;
|
||||
|
||||
match result {
|
||||
ToolResult::Success(value) => {
|
||||
// Should fail due to missing document_id
|
||||
assert!(!value["success"].as_bool().unwrap_or(true));
|
||||
}
|
||||
ToolResult::Error(_) => {
|
||||
// This is also acceptable
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,573 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData};
|
||||
use docx_mcp::pure_converter::PureRustConverter;
|
||||
use docx_mcp::docx_tools::DocxToolsProvider;
|
||||
use docx_mcp::security::SecurityConfig;
|
||||
use mcp_core::{ToolProvider, ToolResult};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const PERFORMANCE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const STRESS_TEST_ITERATIONS: usize = 100;
|
||||
|
||||
#[test]
|
||||
fn test_large_document_performance() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
|
||||
let start = Instant::now();
|
||||
let doc_id = handler.create_document().unwrap();
|
||||
let creation_time = start.elapsed();
|
||||
|
||||
println!("Document creation took: {:?}", creation_time);
|
||||
assert!(creation_time < Duration::from_millis(500), "Document creation should be fast");
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
// Add substantial content
|
||||
for i in 0..1000 {
|
||||
if i % 50 == 0 {
|
||||
handler.add_heading(&doc_id, &format!("Section {}", i / 50 + 1), 2)?;
|
||||
}
|
||||
|
||||
let content = format!(
|
||||
"This is paragraph number {} in our performance test. It contains enough text to make the test meaningful and simulate real-world usage patterns. The paragraph includes various punctuation marks, numbers like {}, and other elements that might affect processing performance.",
|
||||
i, i * 7
|
||||
);
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
|
||||
// Add a table every 100 paragraphs
|
||||
if i % 100 == 99 {
|
||||
let table_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Item".to_string(), "Value".to_string(), "Status".to_string()],
|
||||
vec![format!("Item {}", i), format!("${}.00", i * 10), "Active".to_string()],
|
||||
],
|
||||
headers: Some(vec!["Item".to_string(), "Value".to_string(), "Status".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, table_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
let content_addition_time = start.elapsed();
|
||||
println!("Adding 1000 paragraphs took: {:?}", content_addition_time);
|
||||
assert!(content_addition_time < PERFORMANCE_TIMEOUT, "Content addition took too long");
|
||||
|
||||
// Test text extraction performance
|
||||
let start = Instant::now();
|
||||
let text = handler.extract_text(&doc_id)?;
|
||||
let extraction_time = start.elapsed();
|
||||
|
||||
println!("Text extraction took: {:?}", extraction_time);
|
||||
println!("Extracted text length: {} characters", text.len());
|
||||
assert!(extraction_time < Duration::from_secs(10), "Text extraction should be reasonably fast");
|
||||
assert!(text.len() > 100000, "Should extract substantial amount of text");
|
||||
|
||||
// Test PDF conversion performance
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
let converter = PureRustConverter::new();
|
||||
let pdf_path = temp_dir.path().join("large_performance_test.pdf");
|
||||
|
||||
let start = Instant::now();
|
||||
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
|
||||
let conversion_time = start.elapsed();
|
||||
|
||||
println!("PDF conversion took: {:?}", conversion_time);
|
||||
assert!(conversion_time < PERFORMANCE_TIMEOUT, "PDF conversion took too long");
|
||||
assert!(pdf_path.exists(), "PDF should be created");
|
||||
|
||||
let pdf_size = std::fs::metadata(&pdf_path)?.len();
|
||||
println!("Generated PDF size: {} bytes", pdf_size);
|
||||
assert!(pdf_size > 50000, "PDF should have substantial size");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_concurrent_document_stress() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_path = Arc::new(temp_dir.path().to_path_buf());
|
||||
let results = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let thread_count = 8;
|
||||
let operations_per_thread = 20;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let handles: Vec<_> = (0..thread_count).map(|thread_id| {
|
||||
let temp_path = Arc::clone(&temp_path);
|
||||
let results = Arc::clone(&results);
|
||||
|
||||
thread::spawn(move || -> Result<()> {
|
||||
let mut handler = DocxHandler::new_with_temp_dir(&temp_path)?;
|
||||
let mut local_results = Vec::new();
|
||||
|
||||
for op_id in 0..operations_per_thread {
|
||||
let doc_start = Instant::now();
|
||||
|
||||
// Create document
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Add varied content
|
||||
handler.add_heading(&doc_id, &format!("Thread {} Document {}", thread_id, op_id), 1)?;
|
||||
|
||||
for i in 0..10 {
|
||||
let content = format!("Thread {} operation {} paragraph {}", thread_id, op_id, i);
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
}
|
||||
|
||||
// Add a small table
|
||||
let table_data = TableData {
|
||||
rows: vec![
|
||||
vec!["Col1".to_string(), "Col2".to_string()],
|
||||
vec![format!("T{}", thread_id), format!("O{}", op_id)],
|
||||
],
|
||||
headers: None,
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, table_data)?;
|
||||
|
||||
// Extract text
|
||||
let text = handler.extract_text(&doc_id)?;
|
||||
assert!(text.contains(&format!("Thread {} Document {}", thread_id, op_id)));
|
||||
|
||||
let doc_duration = doc_start.elapsed();
|
||||
local_results.push((thread_id, op_id, doc_duration));
|
||||
|
||||
// Cleanup
|
||||
handler.close_document(&doc_id)?;
|
||||
}
|
||||
|
||||
// Store results
|
||||
{
|
||||
let mut results_guard = results.lock().unwrap();
|
||||
results_guard.extend(local_results);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Wait for all threads
|
||||
for handle in handles {
|
||||
handle.join().unwrap()?;
|
||||
}
|
||||
|
||||
let total_duration = start.elapsed();
|
||||
let results_guard = results.lock().unwrap();
|
||||
|
||||
println!("Concurrent stress test completed in: {:?}", total_duration);
|
||||
println!("Total operations: {}", results_guard.len());
|
||||
|
||||
let avg_duration = results_guard.iter()
|
||||
.map(|(_, _, duration)| duration.as_millis())
|
||||
.sum::<u128>() as f64 / results_guard.len() as f64;
|
||||
|
||||
println!("Average operation duration: {:.2}ms", avg_duration);
|
||||
|
||||
// Verify all operations completed
|
||||
assert_eq!(results_guard.len(), thread_count * operations_per_thread);
|
||||
assert!(total_duration < Duration::from_secs(60), "Stress test took too long");
|
||||
assert!(avg_duration < 1000.0, "Average operation should be under 1 second");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_intensive_operations() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap();
|
||||
|
||||
let mut doc_ids = Vec::new();
|
||||
|
||||
// Create many documents simultaneously
|
||||
for i in 0..50 {
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Add substantial content to each
|
||||
handler.add_heading(&doc_id, &format!("Memory Test Document {}", i), 1)?;
|
||||
|
||||
for j in 0..100 {
|
||||
let content = format!(
|
||||
"Document {} paragraph {}. This paragraph contains substantial text content to test memory usage patterns. It includes various data that might accumulate in memory during processing and needs to be handled efficiently by the system.",
|
||||
i, j
|
||||
);
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
}
|
||||
|
||||
// Add a large table
|
||||
let mut table_rows = vec![vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]];
|
||||
for k in 0..20 {
|
||||
table_rows.push(vec![
|
||||
format!("ID-{}", k),
|
||||
format!("Item-{}", k),
|
||||
format!("Description for item {} in document {}", k, i),
|
||||
]);
|
||||
}
|
||||
|
||||
let table_data = TableData {
|
||||
rows: table_rows,
|
||||
headers: Some(vec!["ID".to_string(), "Name".to_string(), "Description".to_string()]),
|
||||
border_style: Some("single".to_string()),
|
||||
};
|
||||
handler.add_table(&doc_id, table_data)?;
|
||||
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
|
||||
println!("Created {} documents with substantial content", doc_ids.len());
|
||||
|
||||
// Test that all documents are accessible
|
||||
for (i, doc_id) in doc_ids.iter().enumerate() {
|
||||
let text = handler.extract_text(doc_id)?;
|
||||
assert!(text.contains(&format!("Memory Test Document {}", i)));
|
||||
assert!(text.len() > 10000, "Document should have substantial text");
|
||||
}
|
||||
|
||||
// Test batch operations
|
||||
let start = Instant::now();
|
||||
let mut total_text_length = 0;
|
||||
|
||||
for doc_id in &doc_ids {
|
||||
let text = handler.extract_text(doc_id)?;
|
||||
total_text_length += text.len();
|
||||
}
|
||||
|
||||
let batch_extraction_time = start.elapsed();
|
||||
println!("Batch text extraction took: {:?}", batch_extraction_time);
|
||||
println!("Total extracted text: {} characters", total_text_length);
|
||||
|
||||
assert!(batch_extraction_time < Duration::from_secs(30), "Batch extraction should be reasonable");
|
||||
assert!(total_text_length > 500000, "Should extract substantial total text");
|
||||
|
||||
// Cleanup all documents
|
||||
for doc_id in doc_ids {
|
||||
handler.close_document(&doc_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_performance() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
let mut operation_times = Vec::new();
|
||||
|
||||
// Test document creation performance
|
||||
let start = Instant::now();
|
||||
let create_result = tokio_test::block_on(async {
|
||||
provider.call_tool("create_document", json!({})).await
|
||||
});
|
||||
let creation_time = start.elapsed();
|
||||
operation_times.push(("create_document", creation_time));
|
||||
|
||||
let doc_id = match create_result {
|
||||
ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(),
|
||||
_ => panic!("Failed to create document"),
|
||||
};
|
||||
|
||||
// Test paragraph addition performance
|
||||
let start = Instant::now();
|
||||
for i in 0..100 {
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("Performance test paragraph {} with substantial content for timing measurements", i)
|
||||
});
|
||||
|
||||
let result = tokio_test::block_on(async {
|
||||
provider.call_tool("add_paragraph", args).await
|
||||
});
|
||||
|
||||
match result {
|
||||
ToolResult::Success(_) => {},
|
||||
ToolResult::Error(e) => panic!("Failed to add paragraph {}: {}", i, e),
|
||||
}
|
||||
}
|
||||
let paragraph_addition_time = start.elapsed();
|
||||
operation_times.push(("add_100_paragraphs", paragraph_addition_time));
|
||||
|
||||
// Test heading performance
|
||||
let start = Instant::now();
|
||||
for level in 1..=6 {
|
||||
let args = json!({
|
||||
"document_id": doc_id,
|
||||
"text": format!("Heading Level {}", level),
|
||||
"level": level
|
||||
});
|
||||
|
||||
tokio_test::block_on(async {
|
||||
provider.call_tool("add_heading", args).await
|
||||
});
|
||||
}
|
||||
let heading_time = start.elapsed();
|
||||
operation_times.push(("add_headings", heading_time));
|
||||
|
||||
// Test table performance
|
||||
let start = Instant::now();
|
||||
let table_args = json!({
|
||||
"document_id": doc_id,
|
||||
"rows": [
|
||||
["Product", "Price", "Quantity", "Total"],
|
||||
["Item 1", "$10.00", "5", "$50.00"],
|
||||
["Item 2", "$15.00", "3", "$45.00"],
|
||||
["Item 3", "$12.00", "7", "$84.00"],
|
||||
["Item 4", "$8.00", "10", "$80.00"]
|
||||
]
|
||||
});
|
||||
|
||||
tokio_test::block_on(async {
|
||||
provider.call_tool("add_table", table_args).await
|
||||
});
|
||||
let table_time = start.elapsed();
|
||||
operation_times.push(("add_table", table_time));
|
||||
|
||||
// Test text extraction performance
|
||||
let start = Instant::now();
|
||||
let extract_args = json!({"document_id": doc_id});
|
||||
let extract_result = tokio_test::block_on(async {
|
||||
provider.call_tool("extract_text", extract_args).await
|
||||
});
|
||||
let extraction_time = start.elapsed();
|
||||
operation_times.push(("extract_text", extraction_time));
|
||||
|
||||
match extract_result {
|
||||
ToolResult::Success(value) => {
|
||||
let text = value["text"].as_str().unwrap();
|
||||
println!("Extracted text length: {} characters", text.len());
|
||||
assert!(text.len() > 5000, "Should extract substantial text");
|
||||
},
|
||||
ToolResult::Error(e) => panic!("Text extraction failed: {}", e),
|
||||
}
|
||||
|
||||
// Test metadata retrieval performance
|
||||
let start = Instant::now();
|
||||
let metadata_args = json!({"document_id": doc_id});
|
||||
tokio_test::block_on(async {
|
||||
provider.call_tool("get_metadata", metadata_args).await
|
||||
});
|
||||
let metadata_time = start.elapsed();
|
||||
operation_times.push(("get_metadata", metadata_time));
|
||||
|
||||
// Print performance results
|
||||
println!("\nMCP Tool Performance Results:");
|
||||
for (operation, duration) in &operation_times {
|
||||
println!("{}: {:?}", operation, duration);
|
||||
}
|
||||
|
||||
// Verify reasonable performance
|
||||
for (operation, duration) in &operation_times {
|
||||
match operation.as_ref() {
|
||||
"create_document" => assert!(duration < &Duration::from_millis(500), "Document creation too slow"),
|
||||
"add_100_paragraphs" => assert!(duration < &Duration::from_secs(10), "Paragraph addition too slow"),
|
||||
"extract_text" => assert!(duration < &Duration::from_secs(5), "Text extraction too slow"),
|
||||
_ => assert!(duration < &Duration::from_secs(2), "Operation {} too slow", operation),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_overhead_performance() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
// Test with default (permissive) security
|
||||
let default_provider = DocxToolsProvider::new();
|
||||
|
||||
// Test with restrictive security
|
||||
let restrictive_config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
max_document_size: 1024 * 1024, // 1MB
|
||||
max_open_documents: 10,
|
||||
allow_external_tools: false,
|
||||
allow_network: false,
|
||||
..Default::default()
|
||||
};
|
||||
let restrictive_provider = DocxToolsProvider::new_with_security(restrictive_config);
|
||||
|
||||
let operations = vec![
|
||||
("list_documents", json!({})),
|
||||
("get_security_info", json!({})),
|
||||
];
|
||||
|
||||
for (operation, args) in operations {
|
||||
// Test default provider
|
||||
let start = Instant::now();
|
||||
let _result = tokio_test::block_on(async {
|
||||
default_provider.call_tool(operation, args.clone()).await
|
||||
});
|
||||
let default_time = start.elapsed();
|
||||
|
||||
// Test restrictive provider
|
||||
let start = Instant::now();
|
||||
let _result = tokio_test::block_on(async {
|
||||
restrictive_provider.call_tool(operation, args.clone()).await
|
||||
});
|
||||
let restrictive_time = start.elapsed();
|
||||
|
||||
println!("Operation {}: Default={:?}, Restrictive={:?}",
|
||||
operation, default_time, restrictive_time);
|
||||
|
||||
// Security overhead should be minimal
|
||||
let overhead_ratio = restrictive_time.as_nanos() as f64 / default_time.as_nanos() as f64;
|
||||
assert!(overhead_ratio < 3.0, "Security overhead too high for {}: {}x", operation, overhead_ratio);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conversion_performance_scaling() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let converter = PureRustConverter::new();
|
||||
|
||||
let document_sizes = vec![10, 50, 100, 250];
|
||||
let mut performance_data = Vec::new();
|
||||
|
||||
for &size in &document_sizes {
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path())?;
|
||||
let doc_id = handler.create_document()?;
|
||||
|
||||
// Create document with specified number of paragraphs
|
||||
handler.add_heading(&doc_id, &format!("Test Document - {} paragraphs", size), 1)?;
|
||||
|
||||
for i in 0..size {
|
||||
let content = format!("Paragraph {} content for performance scaling test. This paragraph contains enough text to make the performance test meaningful and realistic.", i);
|
||||
handler.add_paragraph(&doc_id, &content, None)?;
|
||||
|
||||
if i % 20 == 19 {
|
||||
handler.add_heading(&doc_id, &format!("Section {}", i / 20 + 1), 2)?;
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = handler.get_metadata(&doc_id)?;
|
||||
|
||||
// Test text extraction scaling
|
||||
let start = Instant::now();
|
||||
let text = handler.extract_text(&doc_id)?;
|
||||
let extraction_time = start.elapsed();
|
||||
|
||||
// Test PDF conversion scaling
|
||||
let pdf_path = temp_dir.path().join(format!("scale_test_{}.pdf", size));
|
||||
let start = Instant::now();
|
||||
converter.convert_docx_to_pdf(&metadata.path, &pdf_path)?;
|
||||
let conversion_time = start.elapsed();
|
||||
|
||||
performance_data.push((size, text.len(), extraction_time, conversion_time));
|
||||
|
||||
println!("Size: {} paragraphs, Text: {} chars, Extract: {:?}, Convert: {:?}",
|
||||
size, text.len(), extraction_time, conversion_time);
|
||||
|
||||
handler.close_document(&doc_id)?;
|
||||
}
|
||||
|
||||
// Analyze scaling behavior
|
||||
for i in 1..performance_data.len() {
|
||||
let (prev_size, _, prev_extract, prev_convert) = performance_data[i-1];
|
||||
let (curr_size, _, curr_extract, curr_convert) = performance_data[i];
|
||||
|
||||
let size_ratio = curr_size as f64 / prev_size as f64;
|
||||
let extract_ratio = curr_extract.as_nanos() as f64 / prev_extract.as_nanos() as f64;
|
||||
let convert_ratio = curr_convert.as_nanos() as f64 / prev_convert.as_nanos() as f64;
|
||||
|
||||
println!("Size {}→{}: Extract scaling {:.2}, Convert scaling {:.2}",
|
||||
prev_size, curr_size, extract_ratio / size_ratio, convert_ratio / size_ratio);
|
||||
|
||||
// Performance should scale reasonably (not exponentially)
|
||||
assert!(extract_ratio / size_ratio < 3.0, "Text extraction scaling too poor");
|
||||
assert!(convert_ratio / size_ratio < 5.0, "PDF conversion scaling too poor");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_handling_performance() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::env::set_var("TMPDIR", temp_dir.path());
|
||||
|
||||
let provider = DocxToolsProvider::new();
|
||||
let error_operations = vec![
|
||||
("extract_text", json!({"document_id": "nonexistent"})),
|
||||
("add_paragraph", json!({"document_id": "fake", "text": "test"})),
|
||||
("get_metadata", json!({"document_id": "invalid"})),
|
||||
("unknown_tool", json!({})),
|
||||
];
|
||||
|
||||
for (operation, args) in error_operations {
|
||||
let start = Instant::now();
|
||||
|
||||
let result = tokio_test::block_on(async {
|
||||
provider.call_tool(operation, args).await
|
||||
});
|
||||
|
||||
let error_time = start.elapsed();
|
||||
println!("Error handling for {}: {:?}", operation, error_time);
|
||||
|
||||
// Error handling should be fast
|
||||
assert!(error_time < Duration::from_millis(100),
|
||||
"Error handling for {} too slow: {:?}", operation, error_time);
|
||||
|
||||
// Should return appropriate error
|
||||
match result {
|
||||
ToolResult::Error(_) | ToolResult::Success(_) => {}, // Both are acceptable for error cases
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_cleanup_performance() -> Result<()> {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut handler = DocxHandler::new_with_temp_dir(temp_dir.path())?;
|
||||
|
||||
let document_count = 50;
|
||||
let mut doc_ids = Vec::new();
|
||||
|
||||
// Create many documents
|
||||
let creation_start = Instant::now();
|
||||
for i in 0..document_count {
|
||||
let doc_id = handler.create_document()?;
|
||||
handler.add_paragraph(&doc_id, &format!("Document {} content", i), None)?;
|
||||
doc_ids.push(doc_id);
|
||||
}
|
||||
let creation_time = creation_start.elapsed();
|
||||
|
||||
println!("Created {} documents in {:?}", document_count, creation_time);
|
||||
|
||||
// Verify all documents exist
|
||||
let initial_count = handler.list_documents().len();
|
||||
assert_eq!(initial_count, document_count);
|
||||
|
||||
// Test cleanup performance
|
||||
let cleanup_start = Instant::now();
|
||||
for doc_id in doc_ids {
|
||||
handler.close_document(&doc_id)?;
|
||||
}
|
||||
let cleanup_time = cleanup_start.elapsed();
|
||||
|
||||
println!("Cleaned up {} documents in {:?}", document_count, cleanup_time);
|
||||
|
||||
// Verify cleanup worked
|
||||
let final_count = handler.list_documents().len();
|
||||
assert_eq!(final_count, 0);
|
||||
|
||||
// Cleanup should be reasonably fast
|
||||
assert!(cleanup_time < Duration::from_secs(5), "Cleanup took too long");
|
||||
|
||||
let avg_cleanup_time = cleanup_time.as_nanos() / document_count as u128;
|
||||
println!("Average cleanup time per document: {}ns", avg_cleanup_time);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
use docx_mcp::security::{SecurityConfig, SecurityMiddleware, SecurityError};
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rstest::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_security_config() {
|
||||
let config = SecurityConfig::default();
|
||||
|
||||
assert!(!config.readonly_mode);
|
||||
assert!(config.command_whitelist.is_none());
|
||||
assert!(config.command_blacklist.is_none());
|
||||
assert_eq!(config.max_document_size, 100 * 1024 * 1024);
|
||||
assert_eq!(config.max_open_documents, 50);
|
||||
assert!(config.allow_external_tools);
|
||||
assert!(config.allow_network);
|
||||
assert!(!config.sandbox_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_readonly_mode_allows_only_safe_commands() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should allow readonly commands
|
||||
assert!(config.is_command_allowed("open_document"));
|
||||
assert!(config.is_command_allowed("extract_text"));
|
||||
assert!(config.is_command_allowed("get_metadata"));
|
||||
assert!(config.is_command_allowed("search_text"));
|
||||
assert!(config.is_command_allowed("export_to_markdown"));
|
||||
|
||||
// Should block write commands
|
||||
assert!(!config.is_command_allowed("create_document"));
|
||||
assert!(!config.is_command_allowed("add_paragraph"));
|
||||
assert!(!config.is_command_allowed("save_document"));
|
||||
assert!(!config.is_command_allowed("find_and_replace"));
|
||||
assert!(!config.is_command_allowed("convert_to_pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_whitelist() {
|
||||
let mut whitelist = HashSet::new();
|
||||
whitelist.insert("open_document".to_string());
|
||||
whitelist.insert("extract_text".to_string());
|
||||
|
||||
let config = SecurityConfig {
|
||||
command_whitelist: Some(whitelist),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should allow whitelisted commands
|
||||
assert!(config.is_command_allowed("open_document"));
|
||||
assert!(config.is_command_allowed("extract_text"));
|
||||
|
||||
// Should block non-whitelisted commands
|
||||
assert!(!config.is_command_allowed("create_document"));
|
||||
assert!(!config.is_command_allowed("add_paragraph"));
|
||||
assert!(!config.is_command_allowed("get_metadata"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_blacklist() {
|
||||
let mut blacklist = HashSet::new();
|
||||
blacklist.insert("save_document".to_string());
|
||||
blacklist.insert("convert_to_pdf".to_string());
|
||||
|
||||
let config = SecurityConfig {
|
||||
command_blacklist: Some(blacklist),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should allow non-blacklisted commands
|
||||
assert!(config.is_command_allowed("open_document"));
|
||||
assert!(config.is_command_allowed("extract_text"));
|
||||
assert!(config.is_command_allowed("add_paragraph"));
|
||||
|
||||
// Should block blacklisted commands
|
||||
assert!(!config.is_command_allowed("save_document"));
|
||||
assert!(!config.is_command_allowed("convert_to_pdf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitelist_overrides_blacklist() {
|
||||
let mut whitelist = HashSet::new();
|
||||
whitelist.insert("save_document".to_string());
|
||||
|
||||
let mut blacklist = HashSet::new();
|
||||
blacklist.insert("save_document".to_string());
|
||||
|
||||
let config = SecurityConfig {
|
||||
command_whitelist: Some(whitelist),
|
||||
command_blacklist: Some(blacklist),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Whitelist should take precedence
|
||||
assert!(config.is_command_allowed("save_document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_external_tools_restriction() {
|
||||
let config = SecurityConfig {
|
||||
allow_external_tools: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should block conversion commands that might use external tools
|
||||
assert!(!config.is_command_allowed("convert_to_pdf"));
|
||||
assert!(!config.is_command_allowed("convert_to_images"));
|
||||
|
||||
// Should allow other commands
|
||||
assert!(config.is_command_allowed("open_document"));
|
||||
assert!(config.is_command_allowed("add_paragraph"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_middleware_command_check() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let middleware = SecurityMiddleware::new(config);
|
||||
|
||||
let safe_args = json!({"document_id": "test"});
|
||||
|
||||
// Should pass readonly commands
|
||||
let result = middleware.check_command("extract_text", &safe_args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Should fail write commands
|
||||
let result = middleware.check_command("add_paragraph", &safe_args);
|
||||
assert!(matches!(result, Err(SecurityError::CommandNotAllowed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_mode_path_restrictions() {
|
||||
let config = SecurityConfig {
|
||||
sandbox_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let middleware = SecurityMiddleware::new(config);
|
||||
|
||||
// Should allow temp directory paths
|
||||
let temp_args = json!({"path": "/tmp/docx-mcp/test.docx"});
|
||||
let result = middleware.check_command("open_document", &temp_args);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Should block paths outside temp directory
|
||||
let home_args = json!({"path": "/home/user/documents/test.docx"});
|
||||
let result = middleware.check_command("open_document", &home_args);
|
||||
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_size_limits() {
|
||||
use tempfile::NamedTempFile;
|
||||
use std::io::Write;
|
||||
|
||||
let config = SecurityConfig {
|
||||
max_document_size: 100, // 100 bytes limit
|
||||
..Default::default()
|
||||
};
|
||||
let middleware = SecurityMiddleware::new(config);
|
||||
|
||||
// Create a test file larger than limit
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
let large_content = vec![0u8; 200]; // 200 bytes
|
||||
temp_file.write_all(&large_content).unwrap();
|
||||
temp_file.flush().unwrap();
|
||||
|
||||
let args = json!({"path": temp_file.path().to_str().unwrap()});
|
||||
let result = middleware.check_command("open_document", &args);
|
||||
|
||||
assert!(matches!(result, Err(SecurityError::FileTooLarge { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_readonly_commands_list() {
|
||||
let readonly_commands = SecurityConfig::get_readonly_commands();
|
||||
|
||||
// Should include expected readonly commands
|
||||
assert!(readonly_commands.contains("open_document"));
|
||||
assert!(readonly_commands.contains("extract_text"));
|
||||
assert!(readonly_commands.contains("get_metadata"));
|
||||
assert!(readonly_commands.contains("search_text"));
|
||||
assert!(readonly_commands.contains("analyze_formatting"));
|
||||
|
||||
// Should not include write commands
|
||||
assert!(!readonly_commands.contains("create_document"));
|
||||
assert!(!readonly_commands.contains("add_paragraph"));
|
||||
assert!(!readonly_commands.contains("save_document"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_commands_list() {
|
||||
let write_commands = SecurityConfig::get_write_commands();
|
||||
|
||||
// Should include expected write commands
|
||||
assert!(write_commands.contains("create_document"));
|
||||
assert!(write_commands.contains("add_paragraph"));
|
||||
assert!(write_commands.contains("save_document"));
|
||||
assert!(write_commands.contains("find_and_replace"));
|
||||
|
||||
// Should not include readonly commands
|
||||
assert!(!write_commands.contains("open_document"));
|
||||
assert!(!write_commands.contains("extract_text"));
|
||||
assert!(!write_commands.contains("get_metadata"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_summary() {
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
allow_external_tools: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let summary = config.get_summary();
|
||||
assert!(summary.contains("READONLY MODE"));
|
||||
assert!(summary.contains("SANDBOX MODE"));
|
||||
assert!(summary.contains("No external tools"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_security_modes() {
|
||||
let mut whitelist = HashSet::new();
|
||||
whitelist.insert("open_document".to_string());
|
||||
whitelist.insert("extract_text".to_string());
|
||||
|
||||
let config = SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
command_whitelist: Some(whitelist),
|
||||
allow_external_tools: false,
|
||||
allow_network: false,
|
||||
max_document_size: 1024,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should only allow whitelisted readonly commands
|
||||
assert!(config.is_command_allowed("open_document"));
|
||||
assert!(config.is_command_allowed("extract_text"));
|
||||
|
||||
// Should block everything else
|
||||
assert!(!config.is_command_allowed("get_metadata")); // Not in whitelist
|
||||
assert!(!config.is_command_allowed("add_paragraph")); // Not readonly
|
||||
assert!(!config.is_command_allowed("convert_to_pdf")); // External tools disabled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recursive_path_argument_checking() {
|
||||
let config = SecurityConfig {
|
||||
sandbox_mode: true,
|
||||
..Default::default()
|
||||
};
|
||||
let middleware = SecurityMiddleware::new(config);
|
||||
|
||||
// Complex nested arguments with paths
|
||||
let nested_args = json!({
|
||||
"document_id": "test",
|
||||
"options": {
|
||||
"output_path": "/home/user/bad/path.docx",
|
||||
"settings": {
|
||||
"temp_file": "/tmp/safe/path.tmp"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"/home/user/another/bad/path.docx",
|
||||
"/tmp/docx-mcp/safe/path.docx"
|
||||
]
|
||||
});
|
||||
|
||||
let result = middleware.check_command("some_command", &nested_args);
|
||||
assert!(matches!(result, Err(SecurityError::PathNotAllowed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_error_messages() {
|
||||
let error = SecurityError::CommandNotAllowed("dangerous_command".to_string());
|
||||
assert!(error.to_string().contains("dangerous_command"));
|
||||
|
||||
let error = SecurityError::PathNotAllowed("/bad/path".to_string());
|
||||
assert!(error.to_string().contains("/bad/path"));
|
||||
|
||||
let error = SecurityError::FileTooLarge { size: 2000, max_size: 1000 };
|
||||
assert!(error.to_string().contains("2000"));
|
||||
assert!(error.to_string().contains("1000"));
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn readonly_config() -> SecurityConfig {
|
||||
SecurityConfig {
|
||||
readonly_mode: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn sandbox_config() -> SecurityConfig {
|
||||
SecurityConfig {
|
||||
sandbox_mode: true,
|
||||
allow_external_tools: false,
|
||||
allow_network: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[fixture]
|
||||
fn restrictive_config() -> SecurityConfig {
|
||||
let mut whitelist = HashSet::new();
|
||||
whitelist.insert("open_document".to_string());
|
||||
whitelist.insert("extract_text".to_string());
|
||||
|
||||
SecurityConfig {
|
||||
readonly_mode: true,
|
||||
sandbox_mode: true,
|
||||
command_whitelist: Some(whitelist),
|
||||
max_document_size: 1024 * 1024, // 1MB
|
||||
max_open_documents: 5,
|
||||
allow_external_tools: false,
|
||||
allow_network: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("open_document", true)]
|
||||
#[case("extract_text", true)]
|
||||
#[case("get_metadata", true)]
|
||||
#[case("create_document", false)]
|
||||
#[case("add_paragraph", false)]
|
||||
#[case("save_document", false)]
|
||||
fn test_readonly_mode_commands(readonly_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
|
||||
assert_eq!(readonly_config.is_command_allowed(command), expected);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case("open_document", true)]
|
||||
#[case("extract_text", true)]
|
||||
#[case("add_paragraph", false)] // Not in whitelist
|
||||
#[case("get_metadata", false)] // Not in whitelist
|
||||
fn test_restrictive_mode_commands(restrictive_config: SecurityConfig, #[case] command: &str, #[case] expected: bool) {
|
||||
assert_eq!(restrictive_config.is_command_allowed(command), expected);
|
||||
}
|
||||
Reference in New Issue
Block a user