Initial Commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(chmod:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
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
|
||||||
|
if: matrix.rust == 'stable'
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: cargo build --verbose --all-features
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: cargo test --verbose --lib
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: cargo test --verbose --test '*'
|
||||||
|
|
||||||
|
- name: Run doc tests
|
||||||
|
run: cargo test --verbose --doc
|
||||||
|
|
||||||
|
- name: Test with minimal features
|
||||||
|
run: cargo test --verbose --no-default-features
|
||||||
|
|
||||||
|
- name: Test with all features
|
||||||
|
run: cargo test --verbose --all-features
|
||||||
|
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
cargo llvm-cov --all-features --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'
|
||||||
|
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:
|
||||||
|
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 --all-features
|
||||||
|
|
||||||
|
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
|
||||||
|
run: cargo build --release --all-features
|
||||||
|
|
||||||
|
- 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:
|
||||||
|
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
|
||||||
|
run: |
|
||||||
|
# Run integration tests with proper environment setup
|
||||||
|
export TEST_INTEGRATION=1
|
||||||
|
export REDIS_URL="redis://localhost:6379"
|
||||||
|
cargo test --test integration -- --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' }}
|
||||||
|
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
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/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/
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
[package]
|
||||||
|
name = "docx-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Official MCP SDK
|
||||||
|
mcp-server = "0.3"
|
||||||
|
mcp-core = "0.3"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Optional external tool support
|
||||||
|
headless_chrome = { version = "1.0", optional = true }
|
||||||
|
wkhtmltopdf = { version = "0.4", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["embedded-fonts", "pure-rust-pdf"]
|
||||||
|
embedded-fonts = []
|
||||||
|
pure-rust-pdf = []
|
||||||
|
external-tools = ["headless_chrome", "wkhtmltopdf"]
|
||||||
|
full = ["embedded-fonts", "pure-rust-pdf", "external-tools", "tera"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "docx-mcp"
|
||||||
|
path = "src/main.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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
# DOCX MCP Server
|
||||||
|
|
||||||
|
A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation, built with Rust. This server provides AI systems with powerful tools to create, edit, convert, and manage Word documents programmatically.
|
||||||
|
|
||||||
|
## 📖 Table of Contents
|
||||||
|
|
||||||
|
- [Quick Start](#-quick-start)
|
||||||
|
- [AI Tool Integration](#-ai-tool-integration)
|
||||||
|
- [Claude Desktop](#claude-desktop)
|
||||||
|
- [Cursor](#cursor)
|
||||||
|
- [Windsurf](#windsurf-codeium)
|
||||||
|
- [Continue.dev](#continuedev)
|
||||||
|
- [VS Code](#vs-code-with-mcp-extension)
|
||||||
|
- [Features](#-features)
|
||||||
|
- [Real-World Usage Examples](#-real-world-usage-examples-with-ai-assistants)
|
||||||
|
- [Prerequisites](#-prerequisites)
|
||||||
|
- [Installation](#-installation)
|
||||||
|
- [Common Use Cases](#-common-use-cases)
|
||||||
|
- [Available Tools](#available-tools)
|
||||||
|
- [Example Workflows](#example-workflows)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Development](#development)
|
||||||
|
- [Troubleshooting](#-troubleshooting)
|
||||||
|
- [Examples Directory](#-examples-directory)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/docx-mcp.git
|
||||||
|
cd docx-mcp
|
||||||
|
|
||||||
|
# Download embedded fonts for standalone operation (optional but recommended)
|
||||||
|
./download_fonts.sh
|
||||||
|
|
||||||
|
# Build the server (creates a fully standalone binary)
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
# The server is now ready - no external dependencies required!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🎯 Standalone Operation
|
||||||
|
|
||||||
|
This MCP server is designed to work **completely standalone** without requiring LibreOffice, unoconv, or any external tools:
|
||||||
|
|
||||||
|
- ✅ **Pure Rust DOCX parsing** - No external libraries needed
|
||||||
|
- ✅ **Built-in PDF generation** - Creates PDFs without LibreOffice
|
||||||
|
- ✅ **Embedded fonts** - Professional typography included in the binary
|
||||||
|
- ✅ **Native image processing** - PNG/JPG generation without ImageMagick
|
||||||
|
- ✅ **Zero external dependencies** - Single binary deployment
|
||||||
|
|
||||||
|
The server will automatically use external tools if available for enhanced quality, but they are **completely optional**.
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
The server includes comprehensive security features for enterprise and restricted environments:
|
||||||
|
|
||||||
|
### Readonly Mode
|
||||||
|
```bash
|
||||||
|
# Enable readonly mode - only allows document viewing and analysis
|
||||||
|
export DOCX_MCP_READONLY=true
|
||||||
|
./target/release/docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
In readonly mode, only these operations are allowed:
|
||||||
|
- Open and view documents
|
||||||
|
- Extract text and analyze structure
|
||||||
|
- Export to other formats (Markdown, PDF)
|
||||||
|
- Search and word count analysis
|
||||||
|
- Get document metadata and statistics
|
||||||
|
|
||||||
|
### Command Filtering
|
||||||
|
```bash
|
||||||
|
# Whitelist specific commands only
|
||||||
|
export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,export_to_markdown"
|
||||||
|
|
||||||
|
# Or blacklist dangerous commands
|
||||||
|
export DOCX_MCP_BLACKLIST="save_document,convert_to_pdf,merge_documents"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sandbox Mode
|
||||||
|
```bash
|
||||||
|
# Restrict all file operations to temp directory only
|
||||||
|
export DOCX_MCP_SANDBOX=true
|
||||||
|
./target/release/docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
```bash
|
||||||
|
# Set maximum document size (100MB default)
|
||||||
|
export DOCX_MCP_MAX_SIZE=52428800 # 50MB
|
||||||
|
|
||||||
|
# Set maximum number of open documents
|
||||||
|
export DOCX_MCP_MAX_DOCS=20
|
||||||
|
|
||||||
|
# Disable external tools
|
||||||
|
export DOCX_MCP_NO_EXTERNAL_TOOLS=true
|
||||||
|
|
||||||
|
# Disable network operations
|
||||||
|
export DOCX_MCP_NO_NETWORK=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤖 AI Tool Integration
|
||||||
|
|
||||||
|
### Claude Desktop
|
||||||
|
|
||||||
|
Add to your Claude Desktop configuration file:
|
||||||
|
|
||||||
|
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
||||||
|
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"docx": {
|
||||||
|
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"RUST_LOG": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding, restart Claude Desktop. You can then ask Claude to:
|
||||||
|
- "Create a new Word document with our Q4 report"
|
||||||
|
- "Convert this DOCX file to PDF"
|
||||||
|
- "Extract all text from my Word documents"
|
||||||
|
- "Add a table with sales data to the document"
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Add to your Cursor settings (`~/.cursor/config.json` or through Settings UI):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"docx": {
|
||||||
|
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"RUST_LOG": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windsurf (Codeium)
|
||||||
|
|
||||||
|
Add to your Windsurf configuration (`~/.windsurf/config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
"docx": {
|
||||||
|
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"RUST_LOG": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Continue.dev
|
||||||
|
|
||||||
|
Add to your Continue configuration (`~/.continue/config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"title": "Your Model",
|
||||||
|
"provider": "your-provider",
|
||||||
|
"mcp_servers": {
|
||||||
|
"docx": {
|
||||||
|
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||||
|
"args": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### VS Code with MCP Extension
|
||||||
|
|
||||||
|
If using the MCP extension for VS Code, add to your workspace settings (`.vscode/settings.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp.servers": {
|
||||||
|
"docx": {
|
||||||
|
"command": "/absolute/path/to/docx-mcp/target/release/docx-mcp",
|
||||||
|
"args": [],
|
||||||
|
"env": {
|
||||||
|
"RUST_LOG": "info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Features
|
||||||
|
|
||||||
|
### Document Operations
|
||||||
|
- **Create & Open**: Create new documents or open existing DOCX files
|
||||||
|
- **Text Manipulation**: Add paragraphs, headings, lists with full styling support
|
||||||
|
- **Tables**: Create and format tables with custom layouts
|
||||||
|
- **Page Layout**: Add page breaks, set headers/footers
|
||||||
|
- **Find & Replace**: Search and replace text throughout documents
|
||||||
|
- **Text Extraction**: Extract plain text content from documents
|
||||||
|
|
||||||
|
### Conversion Capabilities
|
||||||
|
- **DOCX to PDF**: Convert Word documents to PDF format
|
||||||
|
- Uses LibreOffice/unoconv for high-fidelity conversion
|
||||||
|
- Fallback to basic PDF generation if external tools unavailable
|
||||||
|
- **DOCX to Images**: Convert document pages to PNG/JPG images
|
||||||
|
- Configurable DPI for quality control
|
||||||
|
- Support for multiple image formats
|
||||||
|
- **PDF Operations**: Split, merge, and manipulate PDF files
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
- **Document Metadata**: Track creation time, size, author, etc.
|
||||||
|
- **Styling Support**: Font family, size, bold, italic, underline, colors, alignment
|
||||||
|
- **Multiple Documents**: Handle multiple documents simultaneously
|
||||||
|
- **Temp File Management**: Automatic cleanup of temporary files
|
||||||
|
|
||||||
|
### Professional Templates
|
||||||
|
- **Business Letters**: Professional correspondence with proper formatting
|
||||||
|
- **Resumes**: Modern resume layouts with sections for experience, education, skills
|
||||||
|
- **Reports**: Technical and business reports with table of contents
|
||||||
|
- **Invoices**: Professional invoice templates with itemized billing
|
||||||
|
- **Contracts**: Legal document templates with signature blocks
|
||||||
|
- **Memos**: Corporate memorandum format
|
||||||
|
- **Newsletters**: Multi-column layouts for publications
|
||||||
|
|
||||||
|
### Advanced Document Features
|
||||||
|
- **Table of Contents**: Automatic TOC generation with heading links
|
||||||
|
- **Images & Charts**: Embed images and create data visualizations
|
||||||
|
- **Hyperlinks & Bookmarks**: Internal and external linking with navigation
|
||||||
|
- **Footnotes & Endnotes**: Academic and professional citation support
|
||||||
|
- **Comments & Track Changes**: Collaboration features for document review
|
||||||
|
- **Watermarks**: Confidential, draft, and custom watermarks
|
||||||
|
- **Mail Merge**: Automated personalized document generation
|
||||||
|
- **Custom Styles**: Create and apply consistent formatting themes
|
||||||
|
|
||||||
|
### Analysis & Review Tools
|
||||||
|
- **Document Structure Analysis**: Outline view of headings and sections
|
||||||
|
- **Formatting Analysis**: Detect fonts, styles, and formatting inconsistencies
|
||||||
|
- **Advanced Search**: Pattern matching with context and positioning
|
||||||
|
- **Word Count Statistics**: Detailed metrics including reading time
|
||||||
|
- **Export Options**: Convert to Markdown, HTML, and other formats
|
||||||
|
|
||||||
|
## 💬 Real-World Usage Examples with AI Assistants
|
||||||
|
|
||||||
|
### With Claude Desktop
|
||||||
|
|
||||||
|
Once configured, you can have natural conversations with Claude:
|
||||||
|
|
||||||
|
```
|
||||||
|
You: "Create a professional invoice template for my consulting business"
|
||||||
|
|
||||||
|
Claude will:
|
||||||
|
1. Create a new DOCX document
|
||||||
|
2. Add your company header
|
||||||
|
3. Insert a table for line items
|
||||||
|
4. Add payment terms and footer
|
||||||
|
5. Save it as invoice_template.docx
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
You: "Convert all the Word documents in my reports folder to PDF"
|
||||||
|
|
||||||
|
Claude will:
|
||||||
|
1. List all DOCX files
|
||||||
|
2. Open each document
|
||||||
|
3. Convert to PDF with the same name
|
||||||
|
4. Report completion status
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Cursor/Windsurf
|
||||||
|
|
||||||
|
While coding, you can generate documentation:
|
||||||
|
|
||||||
|
```
|
||||||
|
You: "Generate API documentation from these TypeScript interfaces and save as Word"
|
||||||
|
|
||||||
|
The AI will:
|
||||||
|
1. Parse your code
|
||||||
|
2. Create a formatted DOCX with:
|
||||||
|
- Title and table of contents
|
||||||
|
- Endpoint descriptions
|
||||||
|
- Request/response examples
|
||||||
|
- Error codes table
|
||||||
|
3. Convert to PDF for distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automation Examples
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Ask your AI: "Create a script to generate monthly reports"
|
||||||
|
# The AI can use the DOCX server to:
|
||||||
|
|
||||||
|
async def generate_monthly_report(month, year):
|
||||||
|
# Create document
|
||||||
|
doc = await mcp.call("create_document")
|
||||||
|
|
||||||
|
# Add dynamic content
|
||||||
|
await mcp.call("add_heading", {
|
||||||
|
"document_id": doc.id,
|
||||||
|
"text": f"Monthly Report - {month} {year}",
|
||||||
|
"level": 1
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add data from your database
|
||||||
|
sales_data = fetch_sales_data(month, year)
|
||||||
|
await mcp.call("add_table", {
|
||||||
|
"document_id": doc.id,
|
||||||
|
"rows": format_sales_table(sales_data)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Convert to PDF and email
|
||||||
|
await mcp.call("convert_to_pdf", {
|
||||||
|
"document_id": doc.id,
|
||||||
|
"output_path": f"reports/{year}_{month}_report.pdf"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Prerequisites
|
||||||
|
|
||||||
|
### Required
|
||||||
|
- Rust 1.70+ and Cargo (for building from source)
|
||||||
|
- MCP-compatible AI client (Claude Desktop, Cursor, Windsurf, etc.)
|
||||||
|
|
||||||
|
### Completely Optional (for enhanced features)
|
||||||
|
|
||||||
|
The server works standalone, but can optionally use these tools if available:
|
||||||
|
- **LibreOffice** (recommended): For high-quality DOCX to PDF conversion
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install libreoffice
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install libreoffice
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
# Download from https://www.libreoffice.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
- **PDF to Image Tools** (any one of these):
|
||||||
|
- pdftoppm (part of poppler-utils)
|
||||||
|
- ImageMagick
|
||||||
|
- Ghostscript
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install poppler imagemagick ghostscript
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install poppler-utils imagemagick ghostscript
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Installation
|
||||||
|
|
||||||
|
### Method 1: Build from Source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/docx-mcp.git
|
||||||
|
cd docx-mcp
|
||||||
|
|
||||||
|
# Build the server (uses the build script)
|
||||||
|
./build.sh
|
||||||
|
|
||||||
|
# Or manually with cargo
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# Optional: Enable Chrome-based PDF conversion
|
||||||
|
cargo build --release --features chrome-pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Download Pre-built Binary (Coming Soon)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the latest release
|
||||||
|
curl -L https://github.com/yourusername/docx-mcp/releases/latest/download/docx-mcp-linux-x64 -o docx-mcp
|
||||||
|
chmod +x docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test the server
|
||||||
|
./target/release/docx-mcp --version
|
||||||
|
|
||||||
|
# Check for optional dependencies
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Common Use Cases
|
||||||
|
|
||||||
|
### 1. Document Automation
|
||||||
|
- Generate contracts, invoices, and reports
|
||||||
|
- Mail merge operations
|
||||||
|
- Batch document processing
|
||||||
|
- Template-based document creation
|
||||||
|
|
||||||
|
### 2. Data Export
|
||||||
|
- Export database reports to Word/PDF
|
||||||
|
- Create formatted documentation from JSON/CSV
|
||||||
|
- Generate test reports with charts and tables
|
||||||
|
|
||||||
|
### 3. Document Conversion Pipeline
|
||||||
|
- DOCX → PDF for archival
|
||||||
|
- DOCX → Images for previews
|
||||||
|
- Batch conversion of legacy documents
|
||||||
|
|
||||||
|
### 4. Content Management
|
||||||
|
- Extract text for indexing
|
||||||
|
- Find and replace across multiple documents
|
||||||
|
- Document metadata management
|
||||||
|
|
||||||
|
### 5. Integration Scenarios
|
||||||
|
- CI/CD documentation generation
|
||||||
|
- API documentation from code
|
||||||
|
- Automated report generation from monitoring tools
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
### Document Management
|
||||||
|
|
||||||
|
#### `create_document`
|
||||||
|
Creates a new empty DOCX document.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "create_document",
|
||||||
|
"arguments": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `open_document`
|
||||||
|
Opens an existing DOCX file.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "open_document",
|
||||||
|
"arguments": {
|
||||||
|
"path": "/path/to/document.docx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `save_document`
|
||||||
|
Saves the document to a specified path.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "save_document",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"output_path": "/path/to/output.docx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Addition
|
||||||
|
|
||||||
|
#### `add_paragraph`
|
||||||
|
Adds a styled paragraph to the document.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "add_paragraph",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"text": "This is a paragraph",
|
||||||
|
"style": {
|
||||||
|
"font_size": 12,
|
||||||
|
"bold": true,
|
||||||
|
"color": "#FF0000",
|
||||||
|
"alignment": "center"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `add_heading`
|
||||||
|
Adds a heading (levels 1-6).
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "add_heading",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"text": "Chapter 1",
|
||||||
|
"level": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `add_table`
|
||||||
|
Creates a table with specified data.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "add_table",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"rows": [
|
||||||
|
["Name", "Age", "City"],
|
||||||
|
["Alice", "30", "New York"],
|
||||||
|
["Bob", "25", "Los Angeles"]
|
||||||
|
],
|
||||||
|
"headers": ["Name", "Age", "City"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `add_list`
|
||||||
|
Adds a bulleted or numbered list.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "add_list",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"items": ["First item", "Second item", "Third item"],
|
||||||
|
"ordered": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Document Conversion
|
||||||
|
|
||||||
|
#### `convert_to_pdf`
|
||||||
|
Converts the document to PDF format.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "convert_to_pdf",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"output_path": "/path/to/output.pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `convert_to_images`
|
||||||
|
Converts document pages to images.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "convert_to_images",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"output_dir": "/path/to/images/",
|
||||||
|
"format": "png",
|
||||||
|
"dpi": 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Text Operations
|
||||||
|
|
||||||
|
#### `extract_text`
|
||||||
|
Extracts all text content from the document.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "extract_text",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `find_and_replace`
|
||||||
|
Finds and replaces text in the document.
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "find_and_replace",
|
||||||
|
"arguments": {
|
||||||
|
"document_id": "doc_123",
|
||||||
|
"find_text": "old text",
|
||||||
|
"replace_text": "new text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Workflows
|
||||||
|
|
||||||
|
### Creating a Report
|
||||||
|
```javascript
|
||||||
|
// 1. Create a new document
|
||||||
|
const doc = await mcp.call("create_document", {});
|
||||||
|
|
||||||
|
// 2. Add title
|
||||||
|
await mcp.call("add_heading", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
text: "Annual Report 2024",
|
||||||
|
level: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Add executive summary
|
||||||
|
await mcp.call("add_paragraph", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
text: "This report provides a comprehensive overview...",
|
||||||
|
style: { font_size: 12, alignment: "justify" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Add data table
|
||||||
|
await mcp.call("add_table", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
rows: [
|
||||||
|
["Quarter", "Revenue", "Growth"],
|
||||||
|
["Q1", "$1.2M", "15%"],
|
||||||
|
["Q2", "$1.5M", "25%"]
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Convert to PDF
|
||||||
|
await mcp.call("convert_to_pdf", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
output_path: "./annual_report_2024.pdf"
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Batch Processing Documents
|
||||||
|
```javascript
|
||||||
|
// Open and convert multiple documents
|
||||||
|
const documents = ["doc1.docx", "doc2.docx", "doc3.docx"];
|
||||||
|
|
||||||
|
for (const docPath of documents) {
|
||||||
|
const doc = await mcp.call("open_document", { path: docPath });
|
||||||
|
|
||||||
|
// Extract text for analysis
|
||||||
|
const text = await mcp.call("extract_text", {
|
||||||
|
document_id: doc.document_id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to PDF
|
||||||
|
await mcp.call("convert_to_pdf", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
output_path: docPath.replace(".docx", ".pdf")
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate thumbnails
|
||||||
|
await mcp.call("convert_to_images", {
|
||||||
|
document_id: doc.document_id,
|
||||||
|
output_dir: "./thumbnails/",
|
||||||
|
format: "jpg",
|
||||||
|
dpi: 72
|
||||||
|
});
|
||||||
|
|
||||||
|
await mcp.call("close_document", { document_id: doc.document_id });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The server is built with a modular architecture:
|
||||||
|
|
||||||
|
- **`main.rs`**: MCP server setup and initialization
|
||||||
|
- **`docx_handler.rs`**: Core DOCX manipulation logic
|
||||||
|
- **`converter.rs`**: PDF and image conversion functionality
|
||||||
|
- **`docx_tools.rs`**: MCP tool definitions and handlers
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building from Source
|
||||||
|
```bash
|
||||||
|
cargo build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debug Mode
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### AI Tool Specific Issues
|
||||||
|
|
||||||
|
#### Claude Desktop Not Recognizing the Server
|
||||||
|
1. Ensure the path in config is absolute, not relative
|
||||||
|
2. Restart Claude Desktop after config changes
|
||||||
|
3. Check logs: `tail -f ~/Library/Logs/Claude/mcp.log` (macOS)
|
||||||
|
4. Verify the binary is executable: `chmod +x /path/to/docx-mcp`
|
||||||
|
|
||||||
|
#### Cursor/Windsurf Connection Issues
|
||||||
|
1. Check the MCP server is running: `ps aux | grep docx-mcp`
|
||||||
|
2. Verify port availability: `lsof -i :3000`
|
||||||
|
3. Try reloading the window: `Cmd/Ctrl + R`
|
||||||
|
4. Check developer console for errors: `Cmd/Ctrl + Shift + I`
|
||||||
|
|
||||||
|
#### "Tool not found" Errors
|
||||||
|
1. Ensure the server is properly configured in your AI tool
|
||||||
|
2. Check the server is running with: `RUST_LOG=debug /path/to/docx-mcp`
|
||||||
|
3. Verify tool names match exactly (case-sensitive)
|
||||||
|
|
||||||
|
### Conversion Issues
|
||||||
|
|
||||||
|
#### LibreOffice Not Found
|
||||||
|
```bash
|
||||||
|
# Check if installed
|
||||||
|
which libreoffice
|
||||||
|
|
||||||
|
# Install if missing
|
||||||
|
# macOS
|
||||||
|
brew install libreoffice
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get install libreoffice
|
||||||
|
|
||||||
|
# Fedora
|
||||||
|
sudo dnf install libreoffice
|
||||||
|
```
|
||||||
|
|
||||||
|
#### PDF to Image Conversion Fails
|
||||||
|
```bash
|
||||||
|
# Install at least one converter
|
||||||
|
# Option 1: pdftoppm (fastest)
|
||||||
|
sudo apt-get install poppler-utils # Linux
|
||||||
|
brew install poppler # macOS
|
||||||
|
|
||||||
|
# Option 2: ImageMagick
|
||||||
|
sudo apt-get install imagemagick # Linux
|
||||||
|
brew install imagemagick # macOS
|
||||||
|
|
||||||
|
# Option 3: Ghostscript
|
||||||
|
sudo apt-get install ghostscript # Linux
|
||||||
|
brew install ghostscript # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Errors
|
||||||
|
```bash
|
||||||
|
# Check temp directory permissions
|
||||||
|
ls -la /tmp/docx-mcp/
|
||||||
|
|
||||||
|
# Fix permissions if needed
|
||||||
|
mkdir -p /tmp/docx-mcp
|
||||||
|
chmod 755 /tmp/docx-mcp
|
||||||
|
|
||||||
|
# For system-wide installation
|
||||||
|
sudo chown $USER:$USER /tmp/docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Memory Issues with Large Documents
|
||||||
|
```bash
|
||||||
|
# Increase Rust stack size if needed
|
||||||
|
export RUST_MIN_STACK=8388608 # 8MB
|
||||||
|
./target/release/docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging Tips
|
||||||
|
```bash
|
||||||
|
# Run with verbose logging
|
||||||
|
RUST_LOG=trace ./target/release/docx-mcp
|
||||||
|
|
||||||
|
# Test with the example client
|
||||||
|
python3 example/test_client.py
|
||||||
|
|
||||||
|
# Check MCP communication
|
||||||
|
RUST_LOG=mcp_server=debug ./target/release/docx-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 Examples Directory
|
||||||
|
|
||||||
|
The `example/` directory contains comprehensive examples and templates:
|
||||||
|
|
||||||
|
### Files Included
|
||||||
|
|
||||||
|
- **`test_client.py`** - Python client to test all MCP server functions
|
||||||
|
- **`claude_examples.md`** - Real-world examples for Claude Desktop users
|
||||||
|
- **`config_examples.json`** - Configuration templates for all supported AI tools
|
||||||
|
- **`automation_example.py`** - Advanced automation workflows including:
|
||||||
|
- Monthly report generation
|
||||||
|
- Mail merge operations
|
||||||
|
- Document processing pipelines
|
||||||
|
- Contract generation
|
||||||
|
|
||||||
|
### Running Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test the server functionality
|
||||||
|
python3 example/test_client.py
|
||||||
|
|
||||||
|
# Run automation examples
|
||||||
|
python3 example/automation_example.py
|
||||||
|
|
||||||
|
# View Claude Desktop usage examples
|
||||||
|
cat example/claude_examples.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Categories
|
||||||
|
|
||||||
|
1. **Basic Operations**: Create, edit, save documents
|
||||||
|
2. **Formatting**: Styles, tables, lists, headers/footers
|
||||||
|
3. **Conversion**: DOCX to PDF, DOCX to images
|
||||||
|
4. **Automation**: Batch processing, mail merge, report generation
|
||||||
|
5. **Integration**: Working with CSV data, template processing
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Here's how you can help:
|
||||||
|
|
||||||
|
### Areas for Contribution
|
||||||
|
|
||||||
|
- Additional document manipulation features
|
||||||
|
- Support for more conversion formats
|
||||||
|
- Performance optimizations
|
||||||
|
- Documentation improvements
|
||||||
|
- Bug fixes and testing
|
||||||
|
|
||||||
|
### How to Contribute
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone your fork
|
||||||
|
git clone https://github.com/yourusername/docx-mcp.git
|
||||||
|
cd docx-mcp
|
||||||
|
|
||||||
|
# Install development dependencies
|
||||||
|
cargo install cargo-watch cargo-expand
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Run with watch mode for development
|
||||||
|
cargo watch -x run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
|
- Built with the official [MCP Rust SDK](https://github.com/modelcontextprotocol/rust-sdk)
|
||||||
|
- Uses [docx-rs](https://github.com/bokuweb/docx-rs) for DOCX manipulation
|
||||||
|
- PDF generation with [printpdf](https://github.com/fschutt/printpdf)
|
||||||
|
- Image processing with [image-rs](https://github.com/image-rs/image)
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
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);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# 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 = []
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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
+1
Submodule example/MCP-Doc added at 377d05f0a9
@@ -0,0 +1,492 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"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_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+160
@@ -0,0 +1,160 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Prepare a release
|
||||||
|
prepare-release version:
|
||||||
|
# Update version in Cargo.toml
|
||||||
|
sed -i 's/^version = ".*"/version = "{{version}}"/' Cargo.toml
|
||||||
|
# Update dependencies to use new version
|
||||||
|
cargo update
|
||||||
|
# Run full test suite
|
||||||
|
just ci
|
||||||
|
# Generate changelog
|
||||||
|
just changelog
|
||||||
|
# Commit changes
|
||||||
|
git add .
|
||||||
|
git commit -m "chore: prepare release {{version}}"
|
||||||
|
git tag -a "v{{version}}" -m "Release {{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"
|
||||||
Executable
+312
@@ -0,0 +1,312 @@
|
|||||||
|
#!/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,868 @@
|
|||||||
|
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 drawing = Drawing::new()
|
||||||
|
.inline(
|
||||||
|
Inline::new()
|
||||||
|
.extent(width_emu, height_emu)
|
||||||
|
.graphic(
|
||||||
|
Graphic::new()
|
||||||
|
.graphic_data(
|
||||||
|
GraphicData::new()
|
||||||
|
.pic(
|
||||||
|
Pic::new()
|
||||||
|
.blip_fill(image_data.to_vec())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
let field = ComplexField::new()
|
||||||
|
.instruction(&format!("REF {} \\h", bookmark_name))
|
||||||
|
.default_text(display_text);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new().add_complex_field(field);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
let footnote = Footnote::new(&footnote_id)
|
||||||
|
.add_paragraph(
|
||||||
|
Paragraph::new()
|
||||||
|
.add_run(Run::new().add_text(footnote_text))
|
||||||
|
);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new()
|
||||||
|
.add_run(Run::new().add_text(reference_text))
|
||||||
|
.add_footnote_reference(&footnote_id);
|
||||||
|
|
||||||
|
Ok(docx.add_paragraph(paragraph).add_footnote(footnote))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
|
||||||
|
let endnote = Endnote::new(&endnote_id)
|
||||||
|
.add_paragraph(
|
||||||
|
Paragraph::new()
|
||||||
|
.add_run(Run::new().add_text(endnote_text))
|
||||||
|
);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new()
|
||||||
|
.add_run(Run::new().add_text(reference_text))
|
||||||
|
.add_endnote_reference(&endnote_id);
|
||||||
|
|
||||||
|
Ok(docx.add_paragraph(paragraph).add_endnote(endnote))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
paragraph_property = paragraph_property
|
||||||
|
.line_spacing(LineSpacing::new(SpacingType::Auto, spacing.before, spacing.after));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 merge_field = ComplexField::new()
|
||||||
|
.instruction(&format!("MERGEFIELD {} \\* MERGEFORMAT", field))
|
||||||
|
.default_text(&format!("«{}»", field));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new()
|
||||||
|
.add_complex_field(merge_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();
|
||||||
|
|
||||||
|
let comment_obj = Comment::new(&comment_id, author)
|
||||||
|
.date(date)
|
||||||
|
.add_paragraph(
|
||||||
|
Paragraph::new()
|
||||||
|
.add_run(Run::new().add_text(comment))
|
||||||
|
);
|
||||||
|
|
||||||
|
let comment_range_start = CommentRangeStart::new(&comment_id);
|
||||||
|
let comment_range_end = CommentRangeEnd::new(&comment_id);
|
||||||
|
let comment_reference = CommentReference::new(&comment_id);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new()
|
||||||
|
.add_comment_range_start(comment_range_start)
|
||||||
|
.add_run(Run::new().add_text(text))
|
||||||
|
.add_comment_range_end(comment_range_end)
|
||||||
|
.add_run(Run::new().add_comment_reference(comment_reference));
|
||||||
|
|
||||||
|
Ok(docx.add_paragraph(paragraph).add_comment(comment_obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use image::{DynamicImage, ImageFormat, Rgba, RgbaImage};
|
||||||
|
use printpdf::*;
|
||||||
|
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
|
||||||
|
let text = dotext::extract_text(docx_path)
|
||||||
|
.with_context(|| format!("Failed to extract text from {:?}", docx_path))?;
|
||||||
|
|
||||||
|
// 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.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<()> {
|
||||||
|
use lopdf::{Document, Object, ObjectId};
|
||||||
|
|
||||||
|
let mut merged = Document::new();
|
||||||
|
merged.version = "1.5".to_string();
|
||||||
|
|
||||||
|
for pdf_path in pdf_paths {
|
||||||
|
let mut doc = Document::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>> {
|
||||||
|
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_id)) in pages.iter().enumerate() {
|
||||||
|
let mut single_page = Document::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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+1091
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
});
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use mcp_server::{Server, ServerBuilder, ServerOptions};
|
||||||
|
use mcp_core::ToolManager;
|
||||||
|
use tracing::info;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||||
|
|
||||||
|
mod docx_tools;
|
||||||
|
mod docx_handler;
|
||||||
|
mod converter;
|
||||||
|
mod pure_converter;
|
||||||
|
mod advanced_docx;
|
||||||
|
mod security;
|
||||||
|
|
||||||
|
#[cfg(feature = "embedded-fonts")]
|
||||||
|
mod fonts;
|
||||||
|
|
||||||
|
use docx_tools::DocxToolsProvider;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(fmt::layer())
|
||||||
|
.with(EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Load security configuration from environment
|
||||||
|
let security_config = security::SecurityConfig::from_env();
|
||||||
|
info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary());
|
||||||
|
|
||||||
|
let docx_provider = DocxToolsProvider::new_with_security(security_config);
|
||||||
|
|
||||||
|
let options = ServerOptions::default()
|
||||||
|
.with_name("docx-mcp-server")
|
||||||
|
.with_version("0.1.0");
|
||||||
|
|
||||||
|
let server = ServerBuilder::new(options)
|
||||||
|
.with_tool_provider(docx_provider)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
server.run().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+397
@@ -0,0 +1,397 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::env;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/// Load configuration from environment variables
|
||||||
|
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::new();
|
||||||
|
|
||||||
|
if self.readonly_mode {
|
||||||
|
summary.push("📖 READONLY MODE");
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.sandbox_mode {
|
||||||
|
summary.push("🔒 SANDBOX MODE");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.allow_network {
|
||||||
|
summary.push("🌐 No network access");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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("🚀📝✨"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
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
@@ -0,0 +1,457 @@
|
|||||||
|
//! 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
@@ -0,0 +1,509 @@
|
|||||||
|
//! 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
@@ -0,0 +1,392 @@
|
|||||||
|
//! 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
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