diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 166c68b..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(chmod:*)", - "Bash(cargo build:*)", - "Bash(rustc:*)", - "Bash(cargo check:*)", - "Bash(git push:*)", - "Bash(rm:*)" - ], - "deny": [] - } -} \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/release.md b/.github/ISSUE_TEMPLATE/release.md deleted file mode 100644 index 6877b31..0000000 --- a/.github/ISSUE_TEMPLATE/release.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: Release Checklist -about: Checklist for preparing a new release -title: 'Release v[VERSION]' -labels: 'release' -assignees: '' - ---- - -## Pre-release Checklist - -- [ ] All planned features and fixes are merged -- [ ] All tests are passing on main branch -- [ ] Documentation is updated -- [ ] CHANGELOG.md is updated (if maintained separately) -- [ ] Version is updated in Cargo.toml -- [ ] No critical security vulnerabilities in dependencies - -## Release Process - -- [ ] Run `./scripts/release.sh [patch|minor|major|version X.Y.Z]` -- [ ] Verify all CI checks pass -- [ ] Tag is created and pushed -- [ ] GitHub release is created automatically -- [ ] Binaries are built for all platforms -- [ ] Crate is published to crates.io (for stable releases) -- [ ] Docker images are pushed - -## Post-release Tasks - -- [ ] Verify release artifacts are available -- [ ] Test installation from released binaries -- [ ] Update any dependent projects -- [ ] Announce release (if applicable) - -## Release Notes - - - -## Verification Commands - -```bash -# Test the release script (dry run) -./scripts/release.sh patch --dry-run - -# Run pre-release checks -./scripts/release.sh check - -# Create the actual release -./scripts/release.sh patch # or minor/major/version X.Y.Z -``` \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 11db7f6..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,438 +0,0 @@ -name: Continuous Integration - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - schedule: - # Run tests daily at 2 AM UTC - - cron: '0 2 * * *' - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - test: - name: Test Suite - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - rust: [stable, beta, nightly] - exclude: - # Reduce matrix size by excluding some combinations - - os: windows-latest - rust: beta - - os: windows-latest - rust: nightly - - os: macos-latest - rust: beta - include: - # Add minimum supported Rust version - - os: ubuntu-latest - rust: 1.70.0 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ matrix.rust }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-${{ matrix.rust }}- - ${{ runner.os }}-cargo- - - - name: Install system dependencies (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Install system dependencies (macOS) - if: matrix.os == 'macos-latest' - run: | - brew update - brew install pkg-config freetype jpeg libpng - - - name: Check code formatting - if: matrix.rust == 'stable' - run: cargo fmt --all -- --check - - - name: Run Clippy lints (library only) - if: matrix.rust == 'stable' - run: cargo clippy --lib -- -D warnings - - - name: Build project (no extra features) - run: cargo build --verbose - - - name: Run unit tests - run: cargo test --verbose --lib - - - name: Run integration tests (opt-in) - if: contains(github.event.head_commit.message, '[integration]') - run: cargo test --verbose --test args_tests - - - name: Run doc tests (opt-in) - if: contains(github.event.head_commit.message, '[full-ci]') - run: cargo test --verbose --doc - - - name: Test with minimal features (opt-in) - if: contains(github.event.head_commit.message, '[full-ci]') - run: cargo test --verbose --no-default-features --lib - - - name: Test with all features (opt-in) - if: contains(github.event.head_commit.message, '[full-ci]') - run: cargo test --verbose --all-features --lib - - security: - name: Security Audit - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ubuntu-cargo-audit-${{ hashFiles('**/Cargo.lock') }} - - - name: Install cargo-audit - run: cargo install cargo-audit - - - name: Run security audit - run: cargo audit - - - name: Install cargo-deny - run: cargo install cargo-deny - - - name: Check licenses and dependencies - run: cargo deny check - - coverage: - name: Code Coverage - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Generate coverage report (library only) - run: | - cargo llvm-cov --lib --workspace --lcov --output-path lcov.info - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - files: lcov.info - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - - benchmarks: - name: Performance Benchmarks - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[bench]') - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ubuntu-cargo-bench-${{ hashFiles('**/Cargo.lock') }} - - - name: Run benchmarks - run: cargo bench --all-features - - - name: Store benchmark results - uses: benchmark-action/github-action-benchmark@v1 - with: - tool: 'cargo' - output-file-path: target/criterion/reports/index.html - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: true - comment-on-alert: true - alert-threshold: '200%' - fail-on-alert: true - - memory-safety: - name: Memory Safety Check - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@nightly - with: - components: rust-src - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Install Miri - run: rustup component add miri - - - name: Run Miri tests - run: | - cargo miri setup - # Run a subset of tests with Miri (full test suite might be too slow) - cargo miri test --lib -- --test-threads=1 - env: - MIRIFLAGS: -Zmiri-strict-provenance - - docker: - if: contains(github.event.head_commit.message, '[docker]') - name: Docker Build Test - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker image - run: | - cat > Dockerfile << 'EOF' - FROM rust:1.75 as builder - WORKDIR /app - COPY . . - RUN apt-get update && apt-get install -y \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - RUN cargo build --release - - FROM debian:bookworm-slim - RUN apt-get update && apt-get install -y \ - libssl3 \ - libfontconfig1 \ - libfreetype6 \ - libjpeg62-turbo \ - libpng16-16 \ - ca-certificates \ - && rm -rf /var/lib/apt/lists/* - COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/ - EXPOSE 8080 - CMD ["docx-mcp"] - EOF - docker buildx build --tag docx-mcp:test . - - - name: Test Docker container - run: | - # Start container in background - docker run -d --name docx-mcp-test -p 8080:8080 docx-mcp:test - sleep 10 - # Basic health check (adapt based on your server's health endpoint) - docker logs docx-mcp-test - docker stop docx-mcp-test - docker rm docx-mcp-test - - release-check: - name: Release Readiness - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Check that release builds (library only) - run: cargo build --release - - - name: Verify package can be published - run: cargo package --dry-run - - - name: Generate documentation - run: cargo doc --all-features --no-deps - - - name: Check documentation links - run: cargo doc --all-features --no-deps --open || true - - integration: - if: contains(github.event.head_commit.message, '[integration]') - name: Integration Tests - runs-on: ubuntu-latest - services: - # Add any services your integration tests might need - # For example, if you need a test database or cache - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Run integration tests (focused) - run: | - export RUST_LOG=debug - cargo test --test args_tests -- --nocapture --test-threads=1 - env: - RUST_LOG: debug - - stress-test: - name: Stress Testing - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || contains(github.event.head_commit.message, '[stress-test]') - timeout-minutes: 30 - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Build in release mode - run: cargo build --release --all-features - - - name: Run stress tests - run: | - export STRESS_TEST=1 - export RUST_LOG=info - cargo test --release --test performance_tests -- --ignored --test-threads=1 - cargo test --release --test e2e_workflow_tests -- --ignored --test-threads=1 - - notify: - name: Notify Results - runs-on: ubuntu-latest - needs: [test, security, coverage, benchmarks] - if: always() && (github.event_name == 'push' && github.ref == 'refs/heads/main') - steps: - - name: Notify on success - if: ${{ needs.test.result == 'success' && needs.security.result == 'success' && (needs.coverage.result == 'success' || needs.coverage.result == 'skipped') }} - run: | - echo "✅ All CI checks passed for main branch!" - # Add webhook notification here if needed - - - name: Notify on failure - if: ${{ needs.test.result == 'failure' || needs.security.result == 'failure' || needs.coverage.result == 'failure' }} - run: | - echo "❌ CI checks failed for main branch!" - # Add failure notification here if needed - exit 1 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 34b9806..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,490 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*.*.*' - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g., v1.0.0)' - required: true - type: string - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - -jobs: - validate-release: - name: Validate Release - runs-on: ubuntu-latest - outputs: - version: ${{ steps.get-version.outputs.version }} - tag: ${{ steps.get-version.outputs.tag }} - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version from tag or input - id: get-version - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - VERSION="${{ github.event.inputs.version }}" - echo "version=${VERSION#v}" >> $GITHUB_OUTPUT - echo "tag=${VERSION}" >> $GITHUB_OUTPUT - else - VERSION="${GITHUB_REF#refs/tags/}" - echo "version=${VERSION#v}" >> $GITHUB_OUTPUT - echo "tag=${VERSION}" >> $GITHUB_OUTPUT - fi - - - name: Validate version format - run: | - VERSION="${{ steps.get-version.outputs.version }}" - if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then - echo "❌ Invalid version format: $VERSION" - echo "Expected format: X.Y.Z or X.Y.Z-suffix" - exit 1 - fi - echo "✅ Valid version format: $VERSION" - - - name: Check if version matches Cargo.toml - run: | - CARGO_VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') - INPUT_VERSION="${{ steps.get-version.outputs.version }}" - if [ "$CARGO_VERSION" != "$INPUT_VERSION" ]; then - echo "❌ Version mismatch:" - echo " Cargo.toml: $CARGO_VERSION" - echo " Tag/Input: $INPUT_VERSION" - exit 1 - fi - echo "✅ Version matches Cargo.toml: $INPUT_VERSION" - - test: - name: Run Tests - runs-on: ${{ matrix.os }} - needs: validate-release - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - rust: [stable] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - - - name: Install system dependencies (Ubuntu) - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Install system dependencies (macOS) - if: matrix.os == 'macos-latest' - run: | - brew update - brew install pkg-config freetype jpeg libpng - - - name: Run tests (library only) - run: cargo test --verbose --lib - - build: - name: Build Release Artifacts - runs-on: ${{ matrix.job.os }} - needs: [validate-release, test] - strategy: - matrix: - job: - - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, use-cross: false } - - { os: ubuntu-latest, target: x86_64-unknown-linux-musl, use-cross: true } - - { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, use-cross: true } - - { os: ubuntu-latest, target: aarch64-unknown-linux-musl, use-cross: true } - - { os: windows-latest, target: x86_64-pc-windows-msvc, use-cross: false } - - { os: macos-latest, target: x86_64-apple-darwin, use-cross: false } - - { os: macos-latest, target: aarch64-apple-darwin, use-cross: false } - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.job.target }} - - - name: Install cross compilation tools - if: matrix.job.use-cross - run: | - cargo install cross --git https://github.com/cross-rs/cross - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-${{ matrix.job.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - - - name: Install system dependencies (Ubuntu) - if: matrix.job.os == 'ubuntu-latest' && !matrix.job.use-cross - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Install system dependencies (macOS) - if: matrix.job.os == 'macos-latest' - run: | - brew update - brew install pkg-config freetype jpeg libpng - - - name: Build release binary - run: | - if [ "${{ matrix.job.use-cross }}" = "true" ]; then - cross build --release --target ${{ matrix.job.target }} - else - cargo build --release --target ${{ matrix.job.target }} - fi - - - name: Prepare release archive - shell: bash - run: | - VERSION="${{ needs.validate-release.outputs.version }}" - TARGET="${{ matrix.job.target }}" - - # Create staging directory - mkdir -p staging - - # Copy binary - if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then - cp "target/${TARGET}/release/docx-mcp.exe" staging/ - BINARY="docx-mcp.exe" - else - cp "target/${TARGET}/release/docx-mcp" staging/ - BINARY="docx-mcp" - fi - - # Copy additional files - cp README.md staging/ - cp LICENSE staging/ - - # Create archive name - ARCHIVE="docx-mcp-${VERSION}-${TARGET}" - - # Create archive - cd staging - if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then - 7z a "../${ARCHIVE}.zip" * - echo "ARCHIVE=${ARCHIVE}.zip" >> $GITHUB_ENV - else - tar czf "../${ARCHIVE}.tar.gz" * - echo "ARCHIVE=${ARCHIVE}.tar.gz" >> $GITHUB_ENV - fi - cd .. - - # Generate checksums - if [[ "${{ matrix.job.os }}" == "windows-latest" ]]; then - certutil -hashfile "${ARCHIVE}.zip" SHA256 > "${ARCHIVE}.zip.sha256" - else - shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256" - fi - - echo "BINARY=${BINARY}" >> $GITHUB_ENV - - - name: Upload release artifacts - uses: actions/upload-artifact@v4 - with: - name: release-${{ matrix.job.target }} - path: | - ${{ env.ARCHIVE }} - ${{ env.ARCHIVE }}.sha256 - - create-release: - name: Create GitHub Release - runs-on: ubuntu-latest - needs: [validate-release, build] - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Prepare release assets - run: | - mkdir -p release-assets - find artifacts -type f -name "*.tar.gz" -o -name "*.zip" -o -name "*.sha256" | \ - xargs -I {} cp {} release-assets/ - ls -la release-assets/ - - - name: Generate changelog - id: changelog - run: | - VERSION="${{ needs.validate-release.outputs.version }}" - TAG="${{ needs.validate-release.outputs.tag }}" - - # Get previous tag - PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1) - - echo "Generating changelog from ${PREV_TAG} to ${TAG}" - - # Generate changelog - if [ -n "$PREV_TAG" ]; then - CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges ${PREV_TAG}..HEAD) - else - CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges) - fi - - # Create release notes - cat > release-notes.md << EOF - ## What's Changed - - ${CHANGELOG} - - ## Installation - - ### Pre-built Binaries - - Download the appropriate binary for your system: - - - **Linux x86_64**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-gnu.tar.gz\` - - **Linux x86_64 (musl)**: \`docx-mcp-${VERSION}-x86_64-unknown-linux-musl.tar.gz\` - - **Linux ARM64**: \`docx-mcp-${VERSION}-aarch64-unknown-linux-gnu.tar.gz\` - - **macOS Intel**: \`docx-mcp-${VERSION}-x86_64-apple-darwin.tar.gz\` - - **macOS Apple Silicon**: \`docx-mcp-${VERSION}-aarch64-apple-darwin.tar.gz\` - - **Windows x86_64**: \`docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip\` - - ### From Source - - \`\`\`bash - cargo install --git https://github.com/hongkongkiwi/docx-mcp --tag ${TAG} - \`\`\` - - ### Verification - - All binaries are provided with SHA256 checksums for verification: - - \`\`\`bash - # Linux/macOS - shasum -a 256 -c docx-mcp-${VERSION}-your-target.tar.gz.sha256 - - # Windows - certutil -hashfile docx-mcp-${VERSION}-x86_64-pc-windows-msvc.zip SHA256 - \`\`\` - - ## Full Changelog - - **Full Changelog**: https://github.com/hongkongkiwi/docx-mcp/compare/${PREV_TAG}...${TAG} - EOF - - echo "CHANGELOG_FILE=release-notes.md" >> $GITHUB_OUTPUT - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.validate-release.outputs.tag }} - name: Release ${{ needs.validate-release.outputs.tag }} - body_path: ${{ steps.changelog.outputs.CHANGELOG_FILE }} - files: release-assets/* - draft: false - prerelease: ${{ contains(needs.validate-release.outputs.version, '-') }} - generate_release_notes: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-crate: - name: Publish to crates.io - runs-on: ubuntu-latest - needs: [validate-release, create-release] - if: ${{ !contains(needs.validate-release.outputs.version, '-') }} # Only publish stable releases - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Cache Cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ubuntu-cargo-publish-${{ hashFiles('**/Cargo.lock') }} - - - name: Verify package - run: cargo package --dry-run - - - name: Publish to crates.io - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - - docker-release: - name: Build and Push Docker Images - runs-on: ubuntu-latest - needs: [validate-release, create-release] - permissions: - contents: read - packages: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to Docker Hub - if: ${{ secrets.DOCKERHUB_USERNAME && secrets.DOCKERHUB_TOKEN }} - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ghcr.io/${{ github.repository }} - ${{ secrets.DOCKERHUB_USERNAME && format('{0}/docx-mcp', secrets.DOCKERHUB_USERNAME) || '' }} - tags: | - type=ref,event=tag - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - update-docs: - name: Update Documentation - runs-on: ubuntu-latest - needs: [validate-release, create-release] - permissions: - contents: write - pages: write - id-token: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev - - - name: Generate documentation - run: | - cargo doc --all-features --no-deps - echo '' > target/doc/index.html - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./target/doc - cname: docs.example.com # Replace with your custom domain if you have one - - notify-success: - name: Notify Release Success - runs-on: ubuntu-latest - needs: [create-release, publish-crate, docker-release, update-docs] - if: success() - steps: - - name: Success notification - run: | - echo "🎉 Release ${{ needs.validate-release.outputs.tag }} completed successfully!" - echo "- ✅ GitHub release created" - echo "- ✅ Binaries built for all platforms" - echo "- ✅ Published to crates.io" - echo "- ✅ Docker images pushed" - echo "- ✅ Documentation updated" - - notify-failure: - name: Notify Release Failure - runs-on: ubuntu-latest - needs: [validate-release, test, build, create-release, publish-crate, docker-release, update-docs] - if: failure() - steps: - - name: Failure notification - run: | - echo "❌ Release ${{ needs.validate-release.outputs.tag }} failed!" - echo "Please check the workflow logs for details." - exit 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index bb53c65..0000000 --- a/.gitignore +++ /dev/null @@ -1,67 +0,0 @@ -/example/MCP-Doc - -# Rust -target/ -Cargo.lock -**/*.rs.bk -*.pdb - -# IDE -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Test outputs -*.docx -*.pdf -*.png -*.jpg -*.jpeg -images/ -thumbnails/ -output/ - -# Temporary files -/tmp/ -*.tmp -*.temp - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -.venv - -# Logs -*.log -logs/ - -# Documentation build -/docs/_build/ -/docs/.doctrees/ - -# Coverage -*.gcov -*.gcda -*.gcno -coverage/ -lcov.info - -# Profiling -*.prof -perf.data -perf.data.old - -# Dependencies -node_modules/ -vendor/ diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 99a0d8a..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,5624 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser 0.25.1", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if 1.0.4", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anes" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "assert-json-diff" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "assert_matches" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "auto_generate_cdp" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "359220d0b9360b79d17d648d0a3ba1e792ec36bdbc227c8fd0351df3a0415704" -dependencies = [ - "convert_case 0.8.0", - "proc-macro2", - "quote", - "serde", - "serde_json", - "ureq 3.3.0", -] - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "base64 0.22.1", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr 2.8.2", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite 0.24.0", - "tower 0.5.3", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if 1.0.4", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr 2.8.2", - "regex-automata", - "serde", -] - -[[package]] -name = "built" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "caseless" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.2.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "chrono-tz" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" -dependencies = [ - "chrono", - "chrono-tz-build", - "phf", -] - -[[package]] -name = "chrono-tz-build" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" -dependencies = [ - "parse-zoneinfo", - "phf", - "phf_codegen", -] - -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "comrak" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c93ab3577cca16b4a1d80a88c2e0cd8b6e969e51696f0bbb0d1dcb0157109832" -dependencies = [ - "caseless", - "clap", - "derive_builder", - "entities", - "memchr 2.8.2", - "once_cell", - "regex", - "shell-words", - "slug", - "syntect", - "typed-arena", - "unicode_categories", - "xdg", -] - -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if 1.0.4", -] - -[[package]] -name = "criterion" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" -dependencies = [ - "anes", - "cast", - "ciborium", - "clap", - "criterion-plot", - "is-terminal", - "itertools 0.10.5", - "num-traits", - "once_cell", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" -dependencies = [ - "cast", - "itertools 0.10.5", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "deunicode" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "docx-mcp" -version = "0.1.0" -dependencies = [ - "anyhow", - "assert_matches", - "async-trait", - "axum", - "base64 0.22.1", - "chrono", - "clap", - "comrak", - "criterion", - "docx-rs", - "dotext", - "flate2", - "handlebars", - "headless_chrome", - "html-escape", - "html5ever", - "hyper", - "image", - "imageproc", - "include-bytes-plus", - "lopdf 0.34.0", - "mcp-core", - "mcp-server", - "mcp-spec", - "mockito", - "once_cell", - "pretty_assertions", - "printpdf", - "pulldown-cmark", - "quick-xml 0.36.2", - "regex", - "resvg", - "roxmltree", - "rstest", - "rusttype", - "serde", - "serde_json", - "serde_yaml", - "sha2", - "tar", - "tempfile", - "tera", - "test-log", - "thiserror 1.0.69", - "tiny-skia", - "tokio", - "tokio-test", - "tokio-tungstenite 0.21.0", - "toml", - "tower-http", - "tracing", - "tracing-subscriber", - "ureq 2.12.1", - "usvg", - "uuid", - "walkdir", - "wkhtmltopdf", - "zip 0.6.6", -] - -[[package]] -name = "docx-rs" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" -dependencies = [ - "base64 0.22.1", - "image", - "quick-xml 0.36.2", - "serde", - "serde_json", - "thiserror 2.0.18", - "zip 0.6.6", -] - -[[package]] -name = "dotext" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebbebabe29bf24057077dc5a2352253d0c5dc4d254cd024da38fd17dfd4aed6" -dependencies = [ - "quick-xml 0.9.4", - "zip 0.2.8", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "encoding_rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98fd0f24d1fb71a4a6b9330c8ca04cbd4e7cc5d846b54ca74ff376bc7c9f798d" -dependencies = [ - "cfg-if 0.1.10", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if 1.0.4", -] - -[[package]] -name = "entities" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" - -[[package]] -name = "env_filter" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" -dependencies = [ - "log", -] - -[[package]] -name = "env_logger" -version = "0.11.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "log", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-chain" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9435d864e017c3c6afeac1654189b06cdb491cf2ff73dbf0d73b0f292f42ff8" -dependencies = [ - "backtrace", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if 1.0.4", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree", -] - -[[package]] -name = "fontdb" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser 0.24.1", -] - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-timer" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr 2.8.2", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if 1.0.4", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if 1.0.4", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if 1.0.4", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags 2.13.0", - "ignore", - "walkdir", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if 1.0.4", - "crunchy", - "zerocopy", -] - -[[package]] -name = "handlebars" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43ccdfe15a81ab0a8af639e90254227c9a46afd9c5f5b6ec7efaa345c4b0f00" -dependencies = [ - "derive_builder", - "log", - "num-order", - "pest", - "pest_derive", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "headless_chrome" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5754ca220578ad74a5d5174c8ca2ff956fd3b94025f870844de1e910b47dfee5" -dependencies = [ - "anyhow", - "auto_generate_cdp", - "base64 0.22.1", - "derive_builder", - "log", - "rand 0.10.1", - "regex", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tungstenite 0.29.0", - "url", - "which", - "winreg", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "html-escape" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - -[[package]] -name = "hyper" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "ignore" -version = "0.4.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr 2.8.2", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif 0.14.2", - "image-webp 0.2.4", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "image-webp" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" -dependencies = [ - "byteorder-lite", - "quick-error 2.0.1", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error 2.0.1", -] - -[[package]] -name = "imageproc" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" -dependencies = [ - "ab_glyph", - "approx", - "getrandom 0.2.17", - "image", - "itertools 0.12.1", - "nalgebra", - "num", - "rand 0.8.6", - "rand_distr", - "rayon", -] - -[[package]] -name = "imagesize" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" - -[[package]] -name = "imgref" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" - -[[package]] -name = "include-bytes-plus" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "122887eb9acb1544bb0308837039dcfab0ec269f88b46fd2c9a84f3ab124d199" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "is-terminal" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" -dependencies = [ - "cfg-if 1.0.4", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "kurbo" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lopdf" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" -dependencies = [ - "encoding_rs 0.8.35", - "flate2", - "itoa", - "linked-hash-map", - "log", - "md5", - "pom", - "time 0.3.47", - "weezl", -] - -[[package]] -name = "lopdf" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" -dependencies = [ - "chrono", - "encoding_rs 0.8.35", - "flate2", - "indexmap", - "itoa", - "log", - "md-5", - "nom 7.1.3", - "rangemap", - "rayon", - "time 0.3.47", - "weezl", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "matrixmultiply" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "rawpointer", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if 1.0.4", - "rayon", -] - -[[package]] -name = "mcp-core" -version = "0.1.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76673446eba2d1011f0dbc3b8668984f8b1ca2754b05b9c2bcd27d02ad73aa3" -dependencies = [ - "anyhow", - "async-trait", - "futures", - "libc", - "serde", - "serde_json", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "mcp-macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a98bb059ac88c0823eca2b83d0971b15c6ea361dba44dcbdd3d301103baab0b6" -dependencies = [ - "async-trait", - "convert_case 0.6.0", - "mcp-spec", - "proc-macro2", - "quote", - "schemars", - "serde", - "serde_json", - "syn", -] - -[[package]] -name = "mcp-server" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d1d33bbd1f184d136aedf02a21e1342b2a527400a513c07d4fd30846e978039" -dependencies = [ - "anyhow", - "async-trait", - "futures", - "mcp-macros", - "mcp-spec", - "pin-project", - "schemars", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tower 0.4.13", - "tower-service", - "tracing", - "tracing-appender", - "tracing-subscriber", -] - -[[package]] -name = "mcp-spec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a294f4fcf9c3a0b3d168937947ecb906feb691089dd9fbbefc9707c8a4557163" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.21.7", - "chrono", - "schemars", - "serde", - "serde_json", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if 1.0.4", - "digest", -] - -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - -[[package]] -name = "memchr" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "148fab2e51b4f1cfc66da2a7c32981d1d3c083a803978268bb11fe4b86925e7a" -dependencies = [ - "libc", -] - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "memmap2" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" -dependencies = [ - "libc", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "mockito" -version = "1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" -dependencies = [ - "assert-json-diff", - "bytes", - "colored", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "log", - "pin-project-lite", - "rand 0.9.4", - "regex", - "serde_json", - "serde_urlencoded", - "similar", - "tokio", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "msdos_time" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aad9dfe950c057b1bfe9c1f2aa51583a8468ef2a5baba2ebbe06d775efeb7729" -dependencies = [ - "time 0.1.45", - "winapi", -] - -[[package]] -name = "nalgebra" -version = "0.32.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-rational", - "num-traits", - "simba", - "typenum", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "no_std_io2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr 2.8.2", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-modular" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d1aedba5cebbd8d4e97e7e71409b00c11e277e754d1a6701695e23ef6775d7" - -[[package]] -name = "num-order" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" -dependencies = [ - "num-modular", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "onig" -version = "6.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" -dependencies = [ - "bitflags 2.13.0", - "libc", - "once_cell", - "onig_sys", -] - -[[package]] -name = "onig_sys" -version = "69.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "owned_ttf_parser" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e6affeb1632d6ff6a23d2cd40ffed138e82f1532571a26f527c8a284bb2fbb" -dependencies = [ - "ttf-parser 0.15.2", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" -dependencies = [ - "ttf-parser 0.19.2", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser 0.25.1", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if 1.0.4", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "parse-zoneinfo" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" -dependencies = [ - "regex", -] - -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr 2.8.2", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand 0.8.6", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plist" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" -dependencies = [ - "base64 0.22.1", - "indexmap", - "quick-xml 0.39.4", - "serde", - "time 0.3.47", -] - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.0", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "podio" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b18befed8bc2b61abc79a457295e7e838417326da1586050b919414073977f19" - -[[package]] -name = "pom" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" -dependencies = [ - "bstr", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "printpdf" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" -dependencies = [ - "js-sys", - "lopdf 0.31.0", - "owned_ttf_parser 0.19.0", - "time 0.3.47", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "pulldown-cmark" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" -dependencies = [ - "bitflags 2.13.0", - "getopts", - "memchr 2.8.2", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a3a610544419c527d5f51ae1a6ae3db533e25c117d3eed8fce6434f70c5e95" -dependencies = [ - "encoding_rs 0.7.2", - "error-chain", - "memchr 1.0.2", -] - -[[package]] -name = "quick-xml" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" -dependencies = [ - "encoding_rs 0.8.35", - "memchr 2.8.2", -] - -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.6", -] - -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if 1.0.4", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error 2.0.1", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -dependencies = [ - "aho-corasick", - "memchr 2.8.2", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr 2.8.2", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - -[[package]] -name = "resvg" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" -dependencies = [ - "gif 0.13.3", - "image-webp 0.1.3", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia", - "usvg", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if 1.0.4", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "rstest" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" -dependencies = [ - "futures", - "futures-timer", - "rstest_macros", - "rustc_version", -] - -[[package]] -name = "rstest_macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" -dependencies = [ - "cfg-if 1.0.4", - "glob", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn", - "unicode-ident", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rusttype" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff8374aa04134254b7995b63ad3dc41c7f7236f69528b28553da7d72efaa967" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser 0.15.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rustybuzz" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181" -dependencies = [ - "bitflags 2.13.0", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser 0.24.1", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr 2.8.2", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if 1.0.4", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simba" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" -dependencies = [ - "approx", - "num-complex", - "num-traits", - "paste", - "wide", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "slug" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" -dependencies = [ - "deunicode", - "wasm-bindgen", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "svgtypes" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "onig", - "plist", - "regex-syntax", - "serde", - "serde_derive", - "serde_json", - "thiserror 2.0.18", - "walkdir", - "yaml-rust", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "tera" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" -dependencies = [ - "chrono", - "chrono-tz", - "globwalk", - "humansize", - "lazy_static", - "percent-encoding", - "pest", - "pest_derive", - "rand 0.8.6", - "regex", - "serde", - "serde_json", - "slug", - "unicode-segmentation", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "test-log" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b9c218384242b5c89b68303ab6f6fc53a312d923f0c14dc6bb860c6aeee40f1" -dependencies = [ - "env_logger", - "test-log-macros", - "tracing-subscriber", -] - -[[package]] -name = "test-log-core" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c26ef8b00e4d382e59f6a8ddb3cd790b3a5bb29f21a358a9a69ea2f29f13f27b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "test-log-macros" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "944ad38adcbb71eaa682c56bceeb079e4ca82b4b3edc2a0fde5cb297b77dac8d" -dependencies = [ - "syn", - "test-log-core", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread-id" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fbf4c9d56b320106cd64fd024dadfa0be7cb4706725fc44a7d7ce952d820c1" -dependencies = [ - "libc", - "redox_syscall 0.1.57", - "winapi", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if 1.0.4", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error 2.0.1", - "weezl", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if 1.0.4", - "log", - "png 0.17.16", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" -dependencies = [ - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.21.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.24.0", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.13.0", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time 0.3.47", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ttf-parser" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b3e06c9b9d80ed6b745c7159c40b311ad2916abb34a49e9be2653b90db0d8dd" - -[[package]] -name = "ttf-parser" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" - -[[package]] -name = "ttf-parser" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" -dependencies = [ - "core_maths", -] - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.6", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.6", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", -] - -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" - -[[package]] -name = "unicode-ccc" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-roots 1.0.7", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "usvg" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" -dependencies = [ - "base64 0.22.1", - "data-url", - "flate2", - "fontdb", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree", - "rustybuzz", - "simplecss", - "siphasher", - "strict-num", - "svgtypes", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" -dependencies = [ - "cfg-if 1.0.4", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.7", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "which" -version = "8.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" -dependencies = [ - "libc", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr 2.8.2", -] - -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if 1.0.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "wkhtmltopdf" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a238dfc65ae5e3f766f1cbf6d97ddc7d57e68524178739aa535caecc1f6365eb" -dependencies = [ - "lazy_static", - "log", - "quick-error 1.2.3", - "thread-id", - "url", - "wkhtmltox-sys", -] - -[[package]] -name = "wkhtmltox-sys" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab0405f2aa72bed0966b1bf52964ab82035ff4d420bda535f162461875ab1b1d" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "xdg" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" - -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zip" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7341988e4535c60882d5e5f0b7ad0a9a56b080ade8bdb5527cb512f7b2180e0" -dependencies = [ - "flate2", - "msdos_time", - "podio", - "time 0.1.45", -] - -[[package]] -name = "zip" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" -dependencies = [ - "aes", - "byteorder", - "bzip2", - "constant_time_eq", - "crc32fast", - "crossbeam-utils", - "flate2", - "hmac", - "pbkdf2", - "sha1", - "time 0.3.47", - "zstd", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core 0.5.1", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index e718c46..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,160 +0,0 @@ -[package] -name = "docx-mcp" -version = "0.1.0" -edition = "2021" -authors = ["Your Name "] -description = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation" -documentation = "https://docs.rs/docx-mcp" -homepage = "https://github.com/hongkongkiwi/docx-mcp" -repository = "https://github.com/hongkongkiwi/docx-mcp" -readme = "README.md" -license = "MIT" -keywords = ["mcp", "docx", "word", "document", "pdf"] -categories = ["text-processing", "api-bindings", "command-line-utilities"] -exclude = [ - "/.github/*", - "/tests/fixtures/*", - "/example/*", - "/benches/*", - "/.gitignore", - "/deny.toml" -] - -[dependencies] -# Official MCP SDK -mcp-server = "0.1" -mcp-core = "0.1" -mcp-spec = "0.1" - -# Async runtime -tokio = { version = "1.40", features = ["full"] } -async-trait = "0.1" -futures = "0.3" - -# 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 -html-escape = "0.2" - -# Text extraction from DOCX -dotext = "0.1" - -# Template rendering (pure Rust) -handlebars = "6.0" # Template engine -tera = { version = "1.20", optional = true } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -toml = "0.8" -ureq = { version = "2.10", features = ["tls"] } -flate2 = { version = "1.0", features = ["rust_backend"] } -tar = "0.4" -sha2 = "0.10" - -# Error handling and logging -anyhow = "1.0" -thiserror = "1.0" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# File handling -tempfile = "3.10" -walkdir = "2.5" - -# Additional utilities -uuid = { version = "1.10", features = ["v4", "serde"] } -base64 = "0.22" -chrono = { version = "0.4", features = ["serde"] } -regex = "1.10" -once_cell = "1.20" - -# Command line argument parsing -clap = { version = "4.5", features = ["derive", "env"] } - -# HTTP server for HTML interface -axum = { version = "0.7", features = ["ws", "json"] } -tower-http = { version = "0.5", features = ["cors"] } -hyper = { version = "1.4", features = ["full"] } -tokio-tungstenite = "0.21" - -# Optional external tool support -headless_chrome = { version = "1.0", optional = true } -wkhtmltopdf = { version = "0.4", optional = true } - -[features] -default = ["embedded-fonts", "pure-rust-pdf"] -runtime-server = [] -http-server = [] -advanced-docx = [] -embedded-fonts = [] -pure-rust-pdf = [] -external-tools = ["headless_chrome", "wkhtmltopdf"] -full = ["embedded-fonts", "pure-rust-pdf", "external-tools", "tera"] -build-bin = [] -hi-fidelity = [] # placeholder feature flag for high-fidelity rendering backends -hi-fidelity-tables = [] # enable XML injection for true table merges/widths -hi-fidelity-sections = [] # enable XML injection for sectPr (page setup) -hi-fidelity-styles = [] # enable XML injection for custom styles (e.g., TableHeader) -hi-fidelity-lists = [] # enable XML injection for robust numbering definitions -hi-fidelity-toc = [] # enable XML injection for Table of Contents field -hi-fidelity-bookmarks = [] # enable XML injection for bookmarks -hi-fidelity-comments = [] # enable XML injection for comments -hi-fidelity-revisions = [] # enable XML injection for track changes settings - -[build-dependencies] -anyhow = "1.0" - -[[bin]] -name = "docx-mcp" -path = "src/main.rs" -required-features = ["build-bin"] - -[lib] -name = "docx_mcp" -path = "src/lib.rs" - -[dev-dependencies] -# Testing framework -tokio-test = "0.4" -assert_matches = "1.5" -pretty_assertions = "1.4" -rstest = "0.18" -test-log = "0.2" - -# Test utilities -tempfile = "3.10" -uuid = { version = "1.10", features = ["v4"] } -criterion = { version = "0.5", features = ["html_reports"] } - -# Mock and fixtures -mockito = "1.4" -serde_yaml = "0.9" - -[[bench]] -name = "docx_benchmarks" -harness = false \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index cb4d9a7..0000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,338 +0,0 @@ -# docx-mcp Server - Deployment Guide - -## Server Architecture - -This MCP server supports: -- **stdio mode** (default): stdin/stdout for MCP clients. -- **HTTP mode**: Web interface for HTML/browser access over LAN. -- **Templates directory**: User-provided .docx templates for reuse and fill-in generation. -- **High-fidelity PDF conversion**: Via LibreOffice (included in Docker image). - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ DEPLOYMENT MODES │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ Mode 1: stdio (Local MCP Clients) │ -│ ┌───────────┐ stdio ┌──────────────────┐ │ -│ │ MCP │ ◄────────► │ docx-mcp │ │ -│ │ Client │ │ (container) │ │ -│ └───────────┘ └──────────────────┘ │ -│ │ -│ Mode 2: HTTP (HTML Interface - LAN) │ -│ ┌───────────┐ HTTP:3000 ┌──────────────────┐ │ -│ │ Browser │ ◄────────────►│ docx-mcp │ │ -│ │ (HTML) │ │ (container) │ │ -│ └───────────┘ └──────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` - -## Docker Image - -There is now a single, unified Dockerfile that includes: -- HTTP server (HTML interface) -- stdio MCP transport -- LibreOffice (high-fidelity PDF conversion) -- Templates directory support -- Sandboxed, non-root configuration - -Build: - -```bash -docker build -t docx-mcp:full . -``` - -## Deployment - -### HTTP Mode (HTML Interface - LAN) - -Run the HTTP server with templates and output directories mounted: - -```bash -docker run --rm \ - --name docx-mcp-http \ - -p 3000:3000 \ - -v /host/path/templates:/templates:ro \ - -v /host/path/output:/out \ - -e DOCX_MCP_HTTP=true \ - -e DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000 \ - -e DOCX_MCP_TEMPLATES_DIR=/templates \ - -e DOCX_MCP_MAX_SIZE=104857600 \ - -e DOCX_MCP_MAX_DOCS=30 \ - --memory 1g \ - --cpus 1.5 \ - docx-mcp:full -``` - -Access: -- HTML Interface: http://your-server-ip:3000 -- API: http://your-server-ip:3000/api/tools -- WebSocket: ws://your-server-ip:3000/ws - -### stdio Mode (for MCP Clients) - -Useful when launched by an MCP client (e.g., Claude Desktop, Cursor). - -```bash -docker run --rm \ - --name docx-mcp-stdio \ - -v /host/path/templates:/templates:ro \ - -v /host/path/output:/out \ - -e DOCX_MCP_TEMPLATES_DIR=/templates \ - -e DOCX_MCP_MAX_SIZE=104857600 \ - -e DOCX_MCP_MAX_DOCS=30 \ - --memory 1g \ - --cpus 1.5 \ - docx-mcp:full -``` - -In MCP client config, point "command" to "docker run" with these flags. - -## Server Configuration - -### Command Line Arguments - -| Argument | Environment Variable | Description | -|----------|---------------------|-------------| -| `--http-mode` | `DOCX_MCP_HTTP=true` | Enable HTTP server mode | -| `--http-address` | `DOCX_MCP_HTTP_ADDRESS` | HTTP server address (default: 0.0.0.0:3000) | -| `--templates-dir` | `DOCX_MCP_TEMPLATES_DIR` | Directory with template .docx files (default: /templates) | -| `--readonly` | `DOCX_MCP_READONLY=true` | Enable readonly mode | -| `--sandbox` | `DOCX_MCP_SANDBOX=true` | Enable sandbox mode | -| `--no-external-tools` | `DOCX_MCP_NO_EXTERNAL_TOOLS=true` | Disable external tools (e.g., LibreOffice) | -| `--no-network` | `DOCX_MCP_NO_NETWORK=true` | Disable network operations | -| `--max-size` | `DOCX_MCP_MAX_SIZE` | Max document size in bytes | -| `--max-docs` | `DOCX_MCP_MAX_DOCS` | Max concurrent open documents | -| `--whitelist` | `DOCX_MCP_WHITELIST` | Allowed tools (comma-separated) | -| `--blacklist` | `DOCX_MCP_BLACKLIST` | Blocked tools (comma-separated) | - -### Example Configurations - -- HTTP mode with templates: - -```bash -docker run --rm \ - -p 3000:3000 \ - -v /host/path/templates:/templates:ro \ - -e DOCX_MCP_HTTP=true \ - -e DOCX_MCP_TEMPLATES_DIR=/templates \ - docx-mcp:full -``` - -- Readonly HTTP mode (limited tools): - -```bash -docker run --rm \ - -p 3000:3000 \ - -e DOCX_MCP_HTTP=true \ - -e DOCX_MCP_READONLY=true \ - -e DOCX_MCP_WHITELIST="list_templates,open_template,extract_text,get_metadata,search_text" \ - docx-mcp:full -``` - -## API Endpoints - -### HTML Interface -- GET / — Web interface (tool browser + templates panel) - -### REST API -- GET /api/tools — List available tools -- POST /api/call — Call a tool - -### WebSocket -- WS /ws — Real-time communication - -### API Examples - -- List tools: - -```bash -curl http://localhost:3000/api/tools -``` - -- Call a tool: - -```bash -curl -X POST http://localhost:3000/api/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "create_document", - "arguments": {} - }' -``` - -- List templates: - -```bash -curl -X POST http://localhost:3000/api/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "list_templates", - "arguments": {} - }' -``` - -- Open a template: - -```bash -curl -X POST http://localhost:3000/api/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "open_template", - "arguments": { "name": "nda_template.docx" } - }' -``` - -- Generate from template with fill-in fields: - -```bash -curl -X POST http://localhost:3000/api/call \ - -H "Content-Type: application/json" \ - -d '{ - "name": "generate_from_template", - "arguments": { - "template_name": "nda_template.docx", - "output_path": "/out/nda_acme.docx", - "fields": { - "CLIENT_NAME": "Acme Corp", - "EFFECTIVE_DATE": "2025-11-09" - } - } - }' -``` - -## Docker Compose (Production) - -Example with HTTP mode, templates, and output volumes: - -```yaml -version: '3.8' - -services: - docx-mcp: - image: docx-mcp:full - build: - context: . - dockerfile: Dockerfile - read_only: true - cap_drop: - - ALL - tmpfs: - - /tmp/docx-mcp:rw,noexec,nosuid,size=200m - volumes: - - ./templates:/templates:ro - - ./output:/out - ports: - - "3000:3000" - environment: - - RUST_LOG=info - - DOCX_MCP_HTTP=true - - DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000 - - DOCX_MCP_TEMPLATES_DIR=/templates - - DOCX_MCP_MAX_SIZE=104857600 - - DOCX_MCP_MAX_DOCS=30 - deploy: - resources: - limits: - memory: 1G - cpus: '1.5' - restart: unless-stopped - healthcheck: - test: ["CMD", "/usr/local/bin/docx-mcp", "--version"] - interval: 30s - timeout: 5s - retries: 3 -``` - -## Security Configuration - -### Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `DOCX_MCP_HTTP` | `false` | Enable HTTP mode | -| `DOCX_MCP_HTTP_ADDRESS` | `0.0.0.0:3000` | HTTP server address | -| `DOCX_MCP_TEMPLATES_DIR` | `/templates` | Templates directory | -| `DOCX_MCP_READONLY` | `false` | Restrict to read-only operations | -| `DOCX_MCP_SANDBOX` | `true` | Restrict file operations to temp | -| `DOCX_MCP_NO_EXTERNAL_TOOLS` | `true` | Disable external tools | -| `DOCX_MCP_NO_NETWORK` | `true` | Disable network access | -| `DOCX_MCP_MAX_SIZE` | `104857600` | Max document size (bytes) | -| `DOCX_MCP_MAX_DOCS` | `30` | Max concurrent documents | -| `DOCX_MCP_WHITELIST` | - | Allowed tools (comma-separated) | -| `DOCX_MCP_BLACKLIST` | - | Blocked tools (comma-separated) | - -### Security Profiles - -- Readonly HTTP mode: - -```bash -docker run --rm \ - -p 3000:3000 \ - -e DOCX_MCP_HTTP=true \ - -e DOCX_MCP_READONLY=true \ - -e DOCX_MCP_WHITELIST="list_templates,open_template,extract_text,get_metadata,search_text" \ - docx-mcp:full -``` - -- Maximum security: - -```bash -docker run --rm \ - -p 3000:3000 \ - --read-only \ - --cap-drop ALL \ - --tmpfs /tmp/docx-mcp \ - -e DOCX_MCP_HTTP=true \ - -e DOCX_MCP_READONLY=true \ - -e DOCX_MCP_SANDBOX=true \ - -e DOCX_MCP_NO_EXTERNAL_TOOLS=true \ - -e DOCX_MCP_NO_NETWORK=true \ - docx-mcp:full -``` - -## Monitoring - -```bash -# View logs -docker logs -f docx-mcp-http - -# Check resource usage -docker stats docx-mcp-http - -# Verify security -docker inspect --format='{{.HostConfig.ReadOnly}}' docx-mcp-http # Should be true -``` - -## Troubleshooting - -### Common Issues - -1. Port already in use: - - Use a different port: - - -p 8080:8080 -e DOCX_MCP_HTTP_ADDRESS=0.0.0.0:8080 - -2. Permission denied on temp directory: - - Ensure temp directory is writable: - - --tmpfs /tmp/docx-mcp:rw - -3. Out of memory: - - Increase memory: - - --memory 2g - -4. CORS issues in browser: - - CORS is enabled for all origins on LAN by default. - - For production, restrict to specific origins as needed. - -## API Key - -No API key is required. Security relies on: -- OS-level access controls -- Container isolation -- Built-in command security (whitelist/blacklist) - -For LAN deployments, rely on: -- Network-level access controls -- Firewall rules -- Application-level authentication at the bridge diff --git a/DEPLOYMENT_QUICKREF.md b/DEPLOYMENT_QUICKREF.md deleted file mode 100644 index 40ebb5c..0000000 --- a/DEPLOYMENT_QUICKREF.md +++ /dev/null @@ -1,153 +0,0 @@ -# docx-mcp Server - Deployment Quick Reference - -## Key Facts - -| Item | Value | -|------|-------| -| **Transport Method** | stdio (stdin/stdout) | -| **Network Port** | Not required for local use | -| **API Key** | Not required | -| **Authentication** | OS-level + container security | - ---- - -## Port Requirements - -### Local Deployment (Recommended) -**No port required** - the server communicates via stdin/stdout directly. - -### Remote Deployment (Optional) -If remote access is needed, wrap with a stdio-to-network bridge: - -| Bridge Type | Port | Protocol | -|-------------|------|----------| -| WebSocket | 8080 | ws:// | -| TCP | 8080 | tcp:// | - ---- - -## Quick Start - -### Build -```bash -# Minimal (recommended) -docker build -f Dockerfile.sandboxed -t docx-mcp:sandboxed . - -# With LibreOffice (better PDF conversion) -docker build -f Dockerfile.libreoffice -t docx-mcp:libreoffice . -``` - -### Run (Local) -```bash -docker run --rm \ - --name docx-mcp \ - --read-only \ - --cap-drop ALL \ - --tmpfs /tmp/docx-mcp \ - --memory 512m \ - docx-mcp:sandboxed -``` - -### Run (Remote via Docker Compose) -```bash -docker-compose up -d -``` - ---- - -## MCP Client Configuration - -### Claude Desktop -```json -{ - "mcpServers": { - "docx": { - "command": "docker", - "args": [ - "run", "--rm", "--read-only", "--cap-drop ALL", - "--tmpfs /tmp/docx-mcp", "--memory 512m", - "docx-mcp:sandboxed" - ] - } - } -} -``` - -### Cursor -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "docker", - "args": [ - "run", "--rm", "--read-only", "--cap-drop ALL", - "--tmpfs /tmp/docx-mcp", "--memory 512m", - "docx-mcp:sandboxed" - ] - } - } - } -} -``` - ---- - -## Security Profiles - -### Readonly Mode -```bash -docker run --rm \ - -e DOCX_MCP_READONLY=true \ - -e DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,search_text" \ - docx-mcp:sandboxed -``` - -### Maximum Security -```bash -docker run --rm \ - --read-only \ - --cap-drop ALL \ - --network none \ - --tmpfs /tmp/docx-mcp \ - -e DOCX_MCP_READONLY=true \ - -e DOCX_MCP_SANDBOX=true \ - -e DOCX_MCP_NO_EXTERNAL_TOOLS=true \ - -e DOCX_MCP_NO_NETWORK=true \ - docx-mcp:sandboxed -``` - ---- - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `DOCX_MCP_READONLY` | `false` | Restrict to read-only operations | -| `DOCX_MCP_SANDBOX` | `true` | Restrict file operations to temp | -| `DOCX_MCP_NO_EXTERNAL_TOOLS` | `true` | Disable LibreOffice etc. | -| `DOCX_MCP_NO_NETWORK` | `true` | Disable network access | -| `DOCX_MCP_MAX_SIZE` | `52428800` | Max document size (bytes) | -| `DOCX_MCP_MAX_DOCS` | `20` | Max concurrent documents | -| `DOCX_MCP_WHITELIST` | - | Allowed tools (comma-separated) | -| `DOCX_MCP_BLACKLIST` | - | Blocked tools (comma-separated) | - ---- - -## Files Created - -| File | Description | -|------|-------------| -| `Dockerfile.sandboxed` | Minimal security-focused image | -| `Dockerfile.libreoffice` | Full features with LibreOffice | -| `docker-compose.yml` | Production deployment config | -| `DEPLOYMENT.md` | Comprehensive deployment guide | - ---- - -## Summary - -- **Port Required:** No (for local) / 8080 (for remote with bridge) -- **API Key:** No -- **Authentication:** Container isolation + OS controls -- **Recommended:** Local stdio transport with security features enabled diff --git a/Dockerfile b/Dockerfile index b540db4..d69bf34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,95 +1,70 @@ -# Unified Dockerfile for docx-mcp -# Features: -# - HTTP mode (HTML interface) + stdio mode -# - LibreOffice for high-fidelity PDF conversion -# - Templates directory support -# - Sandboxed, non-root, read-only filesystem where possible +# Dockerfile for py-docx-mcp (Python MCP server) - OpenWebUI: MCP (Streamable HTTP) +# Usage: +# docker build -t py-docx-mcp . +# docker run --rm -p 3000:3000 py-docx-mcp +# +# In OpenWebUI: +# - Type: MCP (Streamable HTTP) +# - URL: http://:3000 +# - Auth: Bearer (if DOCX_MCP_API_KEY is set) +# +# Environment: +# DOCX_MCP_API_KEY - API key (Bearer or X-API-Key). Optional but recommended. +# DOCX_MCP_HTTP_HOST - Bind host (default: 0.0.0.0) +# DOCX_MCP_HTTP_PORT - Bind port (default: 3000) +# DOCX_MCP_TEMPLATES_DIR - Templates directory (default: /templates) +# DOCX_MCP_MAX_SIZE - Max document size in bytes (default: 104857600) +# DOCX_MCP_MAX_DOCS - Max open documents (default: 30) +# DOCX_MCP_SANDBOX - Enable sandbox mode (default: true) +# DOCX_MCP_ALLOW_EXTERNAL_TOOLS - Allow external tools (default: false) +# DOCX_MCP_ALLOW_NETWORK - Allow network access (default: false) -# ============================================================ -# Build Stage -# ============================================================ -FROM rust:1.90-slim AS builder +FROM python:3.12-slim AS base -# Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - pkg-config \ - libssl-dev \ - libfontconfig1-dev \ - libfreetype6-dev \ - libjpeg-dev \ - libpng-dev \ - build-essential \ - && rm -rf /var/lib/apt/lists/* +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=off \ + PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app -# Copy manifests and source -COPY Cargo.toml Cargo.lock build.rs ./ -COPY src/ ./src/ -COPY benches/ ./benches/ -COPY assets/ ./assets/ - -# Build with all key features enabled: -# - runtime-server: stdio MCP transport -# - http-server: HTTP + HTML interface -# - advanced-docx: advanced document operations -# - build-bin: enables building the docx-mcp binary -RUN cargo build --release --features "runtime-server http-server advanced-docx build-bin" - -# ============================================================ -# Runtime Stage -# ============================================================ -FROM debian:bookworm-slim AS runtime - -# Install runtime dependencies (including LibreOffice for better PDF conversion) +# System deps (for python-docx, Pillow, and optional external converters) RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl3 \ - libfontconfig1 \ - libfreetype6 \ - libjpeg62-turbo \ - libpng16-16 \ - ca-certificates \ + build-essential \ + libjpeg62-turbo-dev \ + libpng-dev \ + libfreetype6-dev \ + libfontconfig1-dev \ libreoffice \ poppler-utils \ && rm -rf /var/lib/apt/lists/* -# Create non-root user -RUN groupadd -r docxmcp && useradd -r -g docxmcp -s /bin/bash -d /app docxmcp +# Install Python dependencies +COPY pyproject.toml ./ +RUN pip install --upgrade pip && pip install . -WORKDIR /app -RUN chown -R docxmcp:docxmcp /app +# Copy source +COPY src/py_docx ./src/py_docx -# Copy binary from builder -COPY --from=builder /app/target/release/docx-mcp /usr/local/bin/docx-mcp -RUN chmod +x /usr/local/bin/docx-mcp +# Create runtime dirs +RUN mkdir -p /templates /out /tmp/py-docx-mcp -# Create working directories -RUN mkdir -p /tmp/docx-mcp /templates /out && \ - chown -R docxmcp:docxmcp /tmp/docx-mcp /templates /out +# Environment +ENV DOCX_MCP_HTTP_HOST=0.0.0.0 \ + DOCX_MCP_HTTP_PORT=3000 \ + DOCX_MCP_TEMPLATES_DIR=/templates \ + DOCX_MCP_MAX_SIZE=104857600 \ + DOCX_MCP_MAX_DOCS=30 \ + DOCX_MCP_SANDBOX=true \ + DOCX_MCP_ALLOW_EXTERNAL_TOOLS=true \ + DOCX_MCP_ALLOW_NETWORK=false -# Switch to non-root user -USER docxmcp - -# Expose HTTP port (used when running in HTTP mode) +# Expose HTTP port (Streamable HTTP for OpenWebUI) EXPOSE 3000 -# Health check (checks binary is present and executable) -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD /usr/local/bin/docx-mcp --version +# Health check (ensure module is importable) +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD python -c "import py_docx.server; print('ok')" || exit 1 -# Default environment: -# - HTTP disabled by default (use stdio mode). -# - Enable via DOCX_MCP_HTTP=true or --http-mode. -ENV RUST_LOG=info -ENV DOCX_MCP_TEMP=/tmp/docx-mcp -ENV DOCX_MCP_HTTP=false -ENV DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000 -ENV DOCX_MCP_TEMPLATES_DIR=/templates -ENV DOCX_MCP_MAX_SIZE=104857600 -ENV DOCX_MCP_MAX_DOCS=30 - -ENTRYPOINT ["/usr/local/bin/docx-mcp"] - -# Default: stdio mode (for MCP clients). -# To run in HTTP mode, override CMD or set DOCX_MCP_HTTP=true. -CMD [] +# Default: Streamable HTTP for OpenWebUI MCP +ENTRYPOINT ["python", "-m", "py_docx.server"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 34579fc..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 DOCX MCP Server Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index b095f77..0000000 --- a/README.md +++ /dev/null @@ -1,1195 +0,0 @@ -# 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) -- [Transport Modes](#-transport-modes) -- [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 - -# Using environment variables -export DOCX_MCP_READONLY=true -./target/release/docx-mcp - -# Using command line arguments -./target/release/docx-mcp --readonly -``` - -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 - -# Using environment variables -export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,export_to_markdown" - -# Using command line arguments -./target/release/docx-mcp --whitelist open_document,extract_text,get_metadata,export_to_markdown - -# Or blacklist dangerous commands - -# Using environment variables -export DOCX_MCP_BLACKLIST="save_document,convert_to_pdf,merge_documents" - -# Using command line arguments -./target/release/docx-mcp --blacklist save_document,convert_to_pdf,merge_documents -``` - -### Sandbox Mode - -```bash -# Restrict all file operations to temp directory only - -# Using environment variables -export DOCX_MCP_SANDBOX=true -./target/release/docx-mcp - -# Using command line arguments -./target/release/docx-mcp --sandbox -``` - -### Resource Limits - -```bash -# Set maximum document size (100MB default) - -# Using environment variables -export DOCX_MCP_MAX_SIZE=52428800 # 50MB -export DOCX_MCP_MAX_DOCS=20 -export DOCX_MCP_NO_EXTERNAL_TOOLS=true -export DOCX_MCP_NO_NETWORK=true -./target/release/docx-mcp - -# Using command line arguments -./target/release/docx-mcp \ - --max-size 52428800 \ - --max-docs 20 \ - --no-external-tools \ - --no-network -``` - -## 🌐 Transport Modes - -The server supports two transport modes: - -- **stdio (default)**: - - Communicates over stdin/stdout. - - Ideal for MCP clients (Claude Desktop, Cursor, etc.). - -- **HTTP (for HTML/browser access over LAN)**: - - Provides: - - A web interface for browsing and using tools. - - A REST API for programmatic access. - - A WebSocket endpoint for real-time communication. - -To enable HTTP mode: - -```bash -# Using command line -./target/release/docx-mcp --http-mode --http-address 0.0.0.0:3000 - -# Using environment variables -export DOCX_MCP_HTTP=true -export DOCX_MCP_HTTP_ADDRESS=0.0.0.0:3000 -./target/release/docx-mcp -``` - -Access: -- HTML Interface: http://your-server-ip:3000 -- API: http://your-server-ip:3000/api/tools -- WebSocket: ws://your-server-ip:3000/ws - -## 🤖 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" - } - } - } -} -``` - -**With Security Options (using command-line arguments):** - -```json -{ - "mcpServers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": ["--readonly", "--max-size", "52428800", "--no-network"], - "env": { - "RUST_LOG": "info" - } - } - } -} -``` - -**With Security Options (using environment variables):** - -```json -{ - "mcpServers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info", - "DOCX_MCP_READONLY": "true", - "DOCX_MCP_MAX_SIZE": "52428800", - "DOCX_MCP_NO_NETWORK": "true" - } - } - } -} -``` - -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): - -**Basic Configuration:** - -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } -} -``` - -**With Security Options (using command-line arguments):** - -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": ["--sandbox", "--whitelist", "open_document,extract_text,export_to_markdown"], - "env": { - "RUST_LOG": "info" - } - } - } - } -} -``` - -**With Security Options (using environment variables):** - -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info", - "DOCX_MCP_SANDBOX": "true", - "DOCX_MCP_WHITELIST": "open_document,extract_text,export_to_markdown" - } - } - } - } -} -``` - -### Windsurf (Codeium) - -Add to your Windsurf configuration (`~/.windsurf/config.json`): - -**Basic Configuration:** - -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } -} -``` - -**With Security Options (using arguments):** - -```json -{ - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": ["--readonly", "--no-external-tools"], - "env": { - "RUST_LOG": "info" - } - } - } - } -} -``` - -### Continue.dev - -Add to your Continue configuration (`~/.continue/config.json`): - -**Basic Configuration:** - -```json -{ - "models": [ - { - "title": "Your Model", - "provider": "your-provider", - "mcp_servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [] - } - } - } - ] -} -``` - -**With Security Options:** - -```json -{ - "models": [ - { - "title": "Your Model", - "provider": "your-provider", - "mcp_servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": ["--sandbox", "--max-size", "10485760"] - } - } - } - ] -} -``` - -### VS Code with MCP Extension - -If using the MCP extension for VS Code, add to your workspace settings (`.vscode/settings.json`): - -**Basic Configuration:** - -```json -{ - "mcp.servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } -} -``` - -**With Security Options:** - -```json -{ - "mcp.servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": ["--readonly", "--blacklist", "save_document,merge_documents"], - "env": { - "RUST_LOG": "info" - } - } - } -} -``` - -## 🔧 Command Line Arguments - -The DOCX MCP server supports the following command-line arguments for configuration: - -```bash -docx-mcp --help -``` - -### Available Arguments - -| Argument | Environment Variable | Description | Example | -|----------|---------------------|-------------|---------| -| `--http-mode` | `DOCX_MCP_HTTP=true` | Enable HTTP server mode (HTML interface) | `--http-mode` | -| `--http-address` | `DOCX_MCP_HTTP_ADDRESS` | HTTP server address (default: 0.0.0.0:3000) | `--http-address 0.0.0.0:3000` | -| `--templates-dir` | `DOCX_MCP_TEMPLATES_DIR` | Directory with template .docx files (default: /templates) | `--templates-dir /path/to/templates` | -| `--readonly` | `DOCX_MCP_READONLY=true` | Enable readonly mode - only viewing operations | `--readonly` | -| `--whitelist ` | `DOCX_MCP_WHITELIST` | Comma-separated list of allowed commands | `--whitelist open_document,extract_text` | -| `--blacklist ` | `DOCX_MCP_BLACKLIST` | Comma-separated list of forbidden commands | `--blacklist save_document,convert_to_pdf` | -| `--sandbox` | `DOCX_MCP_SANDBOX=true` | Restrict file operations to temp directory only | `--sandbox` | -| `--no-external-tools` | `DOCX_MCP_NO_EXTERNAL_TOOLS=true` | Disable external tools (LibreOffice, etc.) | `--no-external-tools` | -| `--no-network` | `DOCX_MCP_NO_NETWORK=true` | Disable network operations | `--no-network` | -| `--max-size ` | `DOCX_MCP_MAX_SIZE` | Maximum document size in bytes | `--max-size 52428800` | -| `--max-docs ` | `DOCX_MCP_MAX_DOCS` | Maximum number of open documents | `--max-docs 20` | -| `--help` | - | Show help information | `--help` | -| `--version` | - | Show version information | `--version` | - -### Example Usage - -```bash -# Basic usage (stdio mode) -./target/release/docx-mcp - -# HTTP mode for HTML interface -./target/release/docx-mcp --http-mode --http-address 0.0.0.0:3000 - -# Readonly mode with size limit -./target/release/docx-mcp --readonly --max-size 10485760 - -# Sandbox mode with command whitelist -./target/release/docx-mcp --sandbox --whitelist open_document,extract_text,export_to_markdown - -# Multiple security options -./target/release/docx-mcp \ - --readonly \ - --no-external-tools \ - --no-network \ - --max-size 52428800 \ - --max-docs 10 -``` - -**Note:** Command-line arguments take precedence over environment variables when both are specified. - -## 📚 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 - -### Templates and Fill-in Generation - -The server supports a templates directory for reusable document templates: - -- **Templates directory**: - - Configurable via `--templates-dir` or `DOCX_MCP_TEMPLATES_DIR`. - - Intended to be mounted in Docker (e.g., `/templates`). -- **Template tools**: - - `list_templates`: Lists all `.docx` templates available. - - `open_template`: Opens a template by name as a working document. - - `generate_from_template`: Generates a new document from a template with fill-in fields. -- **Fill-in fields**: - - Use placeholders like `{{CLIENT_NAME}}` in your template. - - Provide field values as key-value pairs when calling `generate_from_template`. - -Example: - -```json -{ - "name": "generate_from_template", - "arguments": { - "template_name": "nda_template.docx", - "output_path": "/out/nda_acme.docx", - "fields": { - "CLIENT_NAME": "Acme Corp", - "EFFECTIVE_DATE": "2025-11-09" - } - } -} -``` - -### 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" - } -} -``` - -### Template Tools - -#### `list_templates` -Lists all available templates in the configured templates directory. -```json -{ - "tool": "list_templates", - "arguments": {} -} -``` - -#### `open_template` -Opens a template document by name from the templates directory. -```json -{ - "tool": "open_template", - "arguments": { - "name": "nda_template.docx" - } -} -``` - -#### `generate_from_template` -Generates a new document from a template by filling placeholders like `{{FIELD_NAME}}` with provided values. - -Example: -```json -{ - "tool": "generate_from_template", - "arguments": { - "template_name": "nda_template.docx", - "output_path": "/out/nda_acme.docx", - "fields": { - "CLIENT_NAME": "Acme Corp", - "EFFECTIVE_DATE": "2025-11-09" - } - } -} -``` - -### 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 }); -} -``` - -### Using Templates - -```javascript -// 1. List available templates -const templates = await mcp.call("list_templates", {}); - -// 2. Generate a new document from a template with fill-in fields -const result = await mcp.call("generate_from_template", { - template_name: "nda_template.docx", - output_path: "/out/nda_acme.docx", - fields: { - "CLIENT_NAME": "Acme Corp", - "EFFECTIVE_DATE": "2025-11-09" - } -}); -``` - -## 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 -- **`http_server.rs`**: HTTP server and HTML interface for LAN access - -## 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) diff --git a/RELEASING.md b/RELEASING.md deleted file mode 100644 index 1a26e65..0000000 --- a/RELEASING.md +++ /dev/null @@ -1,249 +0,0 @@ -# Release Guide - -This document describes the release process for docx-mcp. - -## Overview - -The release process is automated using GitHub Actions and includes: - -- Automated testing on multiple platforms -- Building release binaries for all supported targets -- Publishing to crates.io -- Creating GitHub releases with binaries -- Building and pushing Docker images -- Updating documentation - -## Release Types - -### Semantic Versioning - -We follow [Semantic Versioning](https://semver.org/): - -- **MAJOR**: Incompatible API changes -- **MINOR**: New features (backwards compatible) -- **PATCH**: Bug fixes (backwards compatible) - -### Pre-release Versions - -Pre-release versions can include suffixes like: -- `1.0.0-alpha.1` - Alpha releases -- `1.0.0-beta.1` - Beta releases -- `1.0.0-rc.1` - Release candidates - -## Quick Release Process - -For most releases, use the automated release script: - -```bash -# Patch release (1.0.0 -> 1.0.1) -./scripts/release.sh patch - -# Minor release (1.0.0 -> 1.1.0) -./scripts/release.sh minor - -# Major release (1.0.0 -> 2.0.0) -./scripts/release.sh major - -# Specific version -./scripts/release.sh version 1.5.0 - -# Pre-release -./scripts/release.sh version 1.0.0-beta.1 -``` - -## Manual Release Process - -If you need to create a release manually: - -### 1. Pre-release Checks - -```bash -# Run all checks -./scripts/release.sh check - -# Or manually: -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-features -cargo build --release --all-features -cargo package --dry-run -``` - -### 2. Update Version - -Update the version in `Cargo.toml`: - -```toml -[package] -version = "1.2.3" -``` - -Update `Cargo.lock`: - -```bash -cargo update -p docx-mcp -``` - -### 3. Commit and Tag - -```bash -git add Cargo.toml Cargo.lock -git commit -m "Release v1.2.3" -git tag -a "v1.2.3" -m "Release v1.2.3" -git push origin main -git push origin v1.2.3 -``` - -### 4. GitHub Actions - -The release workflow will automatically: - -1. Validate the release -2. Run tests on all platforms -3. Build binaries for all targets -4. Create GitHub release -5. Publish to crates.io (stable releases only) -6. Build and push Docker images -7. Update documentation - -## Supported Platforms - -Release binaries are built for: - -- **Linux**: x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl -- **Linux ARM**: aarch64-unknown-linux-gnu, aarch64-unknown-linux-musl -- **macOS**: x86_64-apple-darwin, aarch64-apple-darwin -- **Windows**: x86_64-pc-windows-msvc - -## Docker Images - -Docker images are published to: - -- GitHub Container Registry: `ghcr.io/hongkongkiwi/docx-mcp` -- Docker Hub: `dockerhub-username/docx-mcp` (if configured) - -Tags include: -- `latest` - Latest stable release -- `v1.2.3` - Specific version -- `1.2.3` - Semantic version -- `1.2` - Major.minor version -- `1` - Major version - -## Publishing to crates.io - -Stable releases (without pre-release suffixes) are automatically published to crates.io. - -### Prerequisites - -1. Set `CARGO_REGISTRY_TOKEN` secret in GitHub repository settings -2. Ensure you have publishing permissions for the crate - -### Manual Publishing - -```bash -# Dry run -cargo publish --dry-run - -# Publish -cargo publish -``` - -## Troubleshooting - -### Release Workflow Fails - -1. Check the Actions tab in GitHub for detailed logs -2. Common issues: - - Version mismatch between tag and Cargo.toml - - Tests failing on specific platforms - - Missing secrets (CARGO_REGISTRY_TOKEN, DOCKERHUB credentials) - -### Version Already Exists - -If you need to recreate a release: - -1. Delete the tag: `git tag -d v1.2.3 && git push origin :v1.2.3` -2. Delete the GitHub release (if created) -3. Create the tag again - -### Docker Build Fails - -1. Check if all dependencies are available in the Docker environment -2. Verify Dockerfile syntax and build context -3. Test locally: `docker build -t docx-mcp:test .` - -### crates.io Publishing Fails - -1. Verify `CARGO_REGISTRY_TOKEN` is set and valid -2. Check if version already exists -3. Ensure all required metadata is in Cargo.toml -4. Run `cargo package --dry-run` to check for issues - -## Security Considerations - -### Signing Releases - -Currently, releases are not cryptographically signed. Consider adding: - -1. GPG signing of Git tags -2. Binary signing with platform-specific tools -3. SBOM (Software Bill of Materials) generation - -### Supply Chain Security - -- Dependencies are audited in CI with `cargo audit` -- Docker images use specific base image versions -- Build reproducibility is enhanced with Rust's deterministic builds - -## Release Checklist - -Use this checklist for important releases: - -- [ ] All planned features are implemented -- [ ] All tests pass locally and in CI -- [ ] Documentation is updated -- [ ] Breaking changes are documented -- [ ] Migration guide is provided (for major releases) -- [ ] Security implications are reviewed -- [ ] Performance regression tests pass -- [ ] Cross-platform compatibility verified -- [ ] Release notes are prepared - -## Post-Release Tasks - -After a release: - -1. **Verify Installation**: Test installation from released binaries -2. **Update Examples**: Update example configurations if needed -3. **Notify Users**: Announce significant releases -4. **Monitor Issues**: Watch for issues related to the new release -5. **Update Dependencies**: Consider updating dependent projects - -## Emergency Releases - -For critical security fixes: - -1. Create a hotfix branch from the affected release tag -2. Apply minimal fix -3. Follow expedited release process -4. Consider yanking affected versions from crates.io if necessary - -```bash -# Yank a version from crates.io (if needed) -cargo yank --version 1.2.3 - -# Un-yank if needed later -cargo yank --version 1.2.3 --undo -``` - -## Release Schedule - -- **Patch releases**: As needed for bug fixes -- **Minor releases**: Monthly or when significant features accumulate -- **Major releases**: Annually or when breaking changes are necessary - -## Getting Help - -- Open an issue for release-related problems -- Check GitHub Actions logs for CI failures -- Review this guide and workflow files for automation details \ No newline at end of file diff --git a/assets/fonts/LiberationMono-Regular.ttf b/assets/fonts/LiberationMono-Regular.ttf deleted file mode 100644 index e774859..0000000 Binary files a/assets/fonts/LiberationMono-Regular.ttf and /dev/null differ diff --git a/assets/fonts/LiberationSans-Bold.ttf b/assets/fonts/LiberationSans-Bold.ttf deleted file mode 100644 index dc5d57f..0000000 Binary files a/assets/fonts/LiberationSans-Bold.ttf and /dev/null differ diff --git a/assets/fonts/LiberationSans-Italic.ttf b/assets/fonts/LiberationSans-Italic.ttf deleted file mode 100644 index 25970d9..0000000 Binary files a/assets/fonts/LiberationSans-Italic.ttf and /dev/null differ diff --git a/assets/fonts/LiberationSans-Regular.ttf b/assets/fonts/LiberationSans-Regular.ttf deleted file mode 100644 index e633985..0000000 Binary files a/assets/fonts/LiberationSans-Regular.ttf and /dev/null differ diff --git a/assets/fonts/NotoSans-Bold.ttf b/assets/fonts/NotoSans-Bold.ttf deleted file mode 100644 index 27131f8..0000000 Binary files a/assets/fonts/NotoSans-Bold.ttf and /dev/null differ diff --git a/assets/fonts/NotoSans-Regular.ttf b/assets/fonts/NotoSans-Regular.ttf deleted file mode 100644 index d552209..0000000 Binary files a/assets/fonts/NotoSans-Regular.ttf and /dev/null differ diff --git a/assets/html_interface.html b/assets/html_interface.html deleted file mode 100644 index 9628381..0000000 --- a/assets/html_interface.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - - DOCX MCP Server - Web Interface - - - -
-

DOCX MCP Server

-

Word Document Processing Interface

-
- -
-
-

Templates

-
-

Loading templates...

-
-
- -
-

Available Tools

-
-
-

Loading tools...

-
-
-
- - - - -
- -
- Connecting... -
- - - - diff --git a/benches/docx_benchmarks.rs b/benches/docx_benchmarks.rs deleted file mode 100644 index 4b415c6..0000000 --- a/benches/docx_benchmarks.rs +++ /dev/null @@ -1,456 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use docx_mcp::pure_converter::PureRustConverter; -use tempfile::TempDir; -use std::time::Duration; - -fn setup_handler() -> (DocxHandler, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let handler = DocxHandler::new_with_temp_dir(temp_dir.path()).unwrap(); - (handler, temp_dir) -} - -fn bench_document_creation(c: &mut Criterion) { - c.bench_function("create_document", |b| { - b.iter_batched( - || setup_handler(), - |(mut handler, _temp_dir)| { - black_box(handler.create_document().unwrap()) - }, - criterion::BatchSize::LargeInput, - ) - }); -} - -fn bench_paragraph_addition(c: &mut Criterion) { - let mut group = c.benchmark_group("add_paragraph"); - - for paragraph_count in [1, 10, 100, 1000].iter() { - group.bench_with_input( - BenchmarkId::new("count", paragraph_count), - paragraph_count, - |b, &count| { - b.iter_batched( - || { - let (mut handler, temp_dir) = setup_handler(); - let doc_id = handler.create_document().unwrap(); - (handler, doc_id, temp_dir) - }, - |(mut handler, doc_id, _temp_dir)| { - for i in 0..count { - let text = format!("This is paragraph number {} with some content", i); - handler.add_paragraph(&doc_id, &text, None).unwrap(); - } - black_box(doc_id) - }, - criterion::BatchSize::LargeInput, - ) - }, - ); - } - group.finish(); -} - -fn bench_styled_paragraph_addition(c: &mut Criterion) { - c.bench_function("add_styled_paragraph", |b| { - b.iter_batched( - || { - let (mut handler, temp_dir) = setup_handler(); - let doc_id = handler.create_document().unwrap(); - let style = DocxStyle { - font_family: Some("Arial".to_string()), - font_size: Some(12), - bold: Some(true), - italic: Some(false), - underline: Some(false), - color: Some("#000000".to_string()), - alignment: Some("left".to_string()), - line_spacing: Some(1.0), - }; - (handler, doc_id, temp_dir, style) - }, - |(mut handler, doc_id, _temp_dir, style)| { - black_box(handler.add_paragraph(&doc_id, "Styled paragraph", Some(style)).unwrap()) - }, - criterion::BatchSize::LargeInput, - ) - }); -} - -fn bench_heading_addition(c: &mut Criterion) { - let mut group = c.benchmark_group("add_heading"); - - for level in 1..=6 { - group.bench_with_input( - BenchmarkId::new("level", level), - &level, - |b, &level| { - b.iter_batched( - || { - let (mut handler, temp_dir) = setup_handler(); - let doc_id = handler.create_document().unwrap(); - (handler, doc_id, temp_dir) - }, - |(mut handler, doc_id, _temp_dir)| { - black_box(handler.add_heading(&doc_id, &format!("Heading Level {}", level), level).unwrap()) - }, - criterion::BatchSize::LargeInput, - ) - }, - ); - } - group.finish(); -} - -fn bench_table_addition(c: &mut Criterion) { - let mut group = c.benchmark_group("add_table"); - - for size in [(2, 2), (5, 5), (10, 10), (20, 10)].iter() { - group.bench_with_input( - BenchmarkId::new("size", format!("{}x{}", size.0, size.1)), - size, - |b, &(rows, cols)| { - b.iter_batched( - || { - let (mut handler, temp_dir) = setup_handler(); - let doc_id = handler.create_document().unwrap(); - - let mut table_rows = Vec::new(); - for i in 0..rows { - let mut row = Vec::new(); - for j in 0..cols { - row.push(format!("Cell {}x{}", i, j)); - } - table_rows.push(row); - } - - let table_data = TableData { - rows: table_rows, - headers: None, - border_style: Some("single".to_string()), - }; - - (handler, doc_id, temp_dir, table_data) - }, - |(mut handler, doc_id, _temp_dir, table_data)| { - black_box(handler.add_table(&doc_id, table_data).unwrap()) - }, - criterion::BatchSize::LargeInput, - ) - }, - ); - } - group.finish(); -} - -fn bench_list_addition(c: &mut Criterion) { - let mut group = c.benchmark_group("add_list"); - - for item_count in [5, 20, 50, 100].iter() { - group.bench_with_input( - BenchmarkId::new("items", item_count), - item_count, - |b, &count| { - b.iter_batched( - || { - let (mut handler, temp_dir) = setup_handler(); - let doc_id = handler.create_document().unwrap(); - - let items: Vec = (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); \ No newline at end of file diff --git a/build.rs b/build.rs deleted file mode 100644 index 1c2f8dd..0000000 --- a/build.rs +++ /dev/null @@ -1,37 +0,0 @@ -use anyhow::Result; -use std::fs; -use std::path::Path; - -fn main() -> Result<()> { - println!("cargo:rerun-if-changed=build.rs"); - - // Create assets directory if it doesn't exist - let fonts_dir = Path::new("assets/fonts"); - fs::create_dir_all(fonts_dir)?; - - // Check if fonts exist, if not, create placeholder files - // In production, you would download actual font files here - let font_files = vec![ - "LiberationSans-Regular.ttf", - "LiberationSans-Bold.ttf", - "LiberationSans-Italic.ttf", - "LiberationMono-Regular.ttf", - "NotoSans-Regular.ttf", - "NotoSans-Bold.ttf", - ]; - - for font_file in font_files { - let font_path = fonts_dir.join(font_file); - if !font_path.exists() { - // For now, we'll create empty placeholder files - // In production, download actual Liberation or Noto fonts (which are open source) - println!("cargo:warning=Font file {} not found. Please download Liberation fonts from https://github.com/liberationfonts/liberation-fonts", font_file); - - // Create a minimal placeholder TTF file (this won't work for actual rendering) - // You should download the actual fonts - fs::write(&font_path, &[0u8; 100])?; - } - } - - Ok(()) -} \ No newline at end of file diff --git a/build.sh b/build.sh deleted file mode 100755 index 46d0cdc..0000000 --- a/build.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/bin/bash - -# Build script for DOCX MCP Server - -set -e - -echo "🔨 Building DOCX MCP Server (Standalone Edition)..." - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Check for Rust -if ! command -v cargo &> /dev/null; then - echo -e "${RED}❌ Cargo not found. Please install Rust.${NC}" - echo "Visit: https://www.rust-lang.org/tools/install" - exit 1 -fi - -# Check if fonts are downloaded -if [ ! -f "assets/fonts/LiberationSans-Regular.ttf" ]; then - echo -e "${YELLOW}📥 Fonts not found. Downloading open-source fonts...${NC}" - if [ -f "./download_fonts.sh" ]; then - ./download_fonts.sh - else - echo -e "${YELLOW}⚠️ Font files not found. The server will still work but with basic fonts.${NC}" - echo -e "${YELLOW} Run ./download_fonts.sh to download professional fonts.${NC}" - mkdir -p assets/fonts - # Create placeholder files so build doesn't fail - touch assets/fonts/LiberationSans-Regular.ttf - touch assets/fonts/LiberationSans-Bold.ttf - touch assets/fonts/LiberationSans-Italic.ttf - touch assets/fonts/LiberationMono-Regular.ttf - touch assets/fonts/NotoSans-Regular.ttf - touch assets/fonts/NotoSans-Bold.ttf - fi -fi - -# Build mode selection -BUILD_MODE=${1:-release} -FEATURES=${2:-} - -if [ "$BUILD_MODE" = "debug" ]; then - echo -e "${YELLOW}📦 Building in debug mode...${NC}" - if [ -n "$FEATURES" ]; then - cargo build --features "$FEATURES" - else - cargo build - fi - BINARY_PATH="target/debug/docx-mcp" -else - echo -e "${YELLOW}📦 Building in release mode...${NC}" - if [ -n "$FEATURES" ]; then - cargo build --release --features "$FEATURES" - else - cargo build --release - fi - BINARY_PATH="target/release/docx-mcp" -fi - -# Check if build succeeded -if [ -f "$BINARY_PATH" ]; then - echo -e "${GREEN}✅ Build successful!${NC}" - echo -e "Binary location: ${GREEN}$BINARY_PATH${NC}" - - # Display standalone features - echo -e "\n${BLUE}🎯 Standalone Features Enabled:${NC}" - echo -e "${GREEN}✓${NC} Pure Rust DOCX parsing" - echo -e "${GREEN}✓${NC} Built-in PDF generation" - echo -e "${GREEN}✓${NC} Embedded fonts" - echo -e "${GREEN}✓${NC} Native image processing" - echo -e "${GREEN}✓${NC} Zero external dependencies required" - - # Check for optional enhancements - echo -e "\n${YELLOW}Optional enhancements (not required):${NC}" - - if command -v libreoffice &> /dev/null; then - echo -e "${GREEN}✓${NC} LibreOffice found (enhanced PDF conversion available)" - else - echo -e "${YELLOW}○${NC} LibreOffice not found (using built-in PDF converter)" - echo " Optional: brew install libreoffice (macOS) or apt-get install libreoffice (Linux)" - fi - - if command -v pdftoppm &> /dev/null; then - echo -e "${GREEN}✓${NC} pdftoppm found (PDF to image conversion available)" - elif command -v convert &> /dev/null; then - echo -e "${GREEN}✓${NC} ImageMagick found (PDF to image conversion available)" - elif command -v gs &> /dev/null; then - echo -e "${GREEN}✓${NC} Ghostscript found (PDF to image conversion available)" - else - echo -e "${YELLOW}○${NC} No PDF to image converter found" - echo " Install one of: poppler-utils, imagemagick, or ghostscript" - fi - - # Create example output directories - mkdir -p example/output example/images example/thumbnails - - echo -e "\n${GREEN}🚀 Ready to run!${NC}" - echo -e "Start the server with: ${GREEN}$BINARY_PATH${NC}" - echo -e "Or with logging: ${GREEN}RUST_LOG=info $BINARY_PATH${NC}" -else - echo -e "${RED}❌ Build failed!${NC}" - exit 1 -fi \ No newline at end of file diff --git a/deny.toml b/deny.toml deleted file mode 100644 index a9426ff..0000000 --- a/deny.toml +++ /dev/null @@ -1,107 +0,0 @@ -# cargo-deny configuration for dependency management and security - -[graph] -targets = [ - { triple = "x86_64-unknown-linux-gnu" }, - { triple = "x86_64-pc-windows-msvc" }, - { triple = "x86_64-apple-darwin" }, - { triple = "aarch64-apple-darwin" }, -] - -[advisories] -# The path where the advisory database is cloned/fetched into -db-path = "~/.cargo/advisory-db" -# The url(s) of the advisory databases to use -db-urls = ["https://github.com/rustsec/advisory-db"] -# The lint level for security vulnerabilities -vulnerability = "deny" -# The lint level for unmaintained crates -unmaintained = "warn" -# The lint level for crates that have been yanked from their source registry -yanked = "warn" -# The lint level for crates with security notices -notice = "warn" -# A list of advisory IDs to ignore. Note that ignored advisories will still -# output a note when they are encountered. -ignore = [ - #"RUSTSEC-0000-0000", -] - -[licenses] -# The confidence threshold for detecting a license from a license text. -confidence-threshold = 0.8 -# List of explicitly allowed licenses -allow = [ - "MIT", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "ISC", - "Unicode-DFS-2016", -] -# List of explicitly disallowed licenses -deny = [ - "GPL-2.0", - "GPL-3.0", - "AGPL-3.0", -] -# Lint level for when multiple versions of the same license are detected -copyleft = "warn" -# Some crates don't have license files and we allow them specifically -exceptions = [ - # Allow ring which has some complex licensing - { allow = ["MIT", "ISC", "OpenSSL"], name = "ring" }, - # webpki has Mozilla's license - { allow = ["ISC", "MIT", "MPL-2.0"], name = "webpki" }, -] - -[[licenses.clarify]] -name = "ring" -# SPDX identifier -expression = "MIT AND ISC AND OpenSSL" -# License file paths -license-files = [ - { path = "LICENSE", hash = 0xbd0eed23 } -] - -[bans] -# Lint level for when multiple versions of the same crate are detected -multiple-versions = "warn" -# Lint level for when a crate version requirement is `*` -wildcards = "allow" -# The graph highlighting used when creating dotgraphs for crates -highlight = "all" -# List of crates that are allowed. Use with care! -allow = [ - #{ name = "ansi_term", version = "=0.11.0" }, -] -# List of crates to deny -deny = [ - # Insecure random number generation - { name = "openssl", version = "*", use-instead = "rustls" }, - # Unmaintained and insecure - { name = "chrono", version = "<0.4.20" }, -] -# Certain crates/versions that will be skipped when doing duplicate detection. -skip = [ - #{ name = "ansi_term", version = "=0.11.0" }, -] -# Similarly to `skip` allows you to skip certain crates from being checked. Unlike -# `skip`, a skipped crate is removed from the crate graph entirely. -skip-tree = [ - #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, -] - -[sources] -# Lint level for what to happen when a crate from a crate registry that is -# not in the allow list is encountered -unknown-registry = "warn" -# Lint level for what to happen when a crate from a git repository that is not -# in the allow list is encountered -unknown-git = "warn" -# List of URLs for allowed crate registries. Defaults to the crates.io index -# if not specified. If it is specified but empty, no registries are allowed. -allow-registry = ["https://github.com/rust-lang/crates.io-index"] -# List of URLs for allowed Git repositories -allow-git = [] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b8bbf9a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,64 +0,0 @@ -version: '3.8' - -# Production deployment for docx-mcp server -# This creates a sandboxed environment with optional WebSocket bridge for remote access - -services: - # WebSocket bridge for remote access (optional) - # Comment out this service if using local stdio transport only - websockify: - image: websockify/websockify - ports: - - "8080:8080" - depends_on: - - docx-mcp - command: ["--web", "/dev/null", "8080", "docx-mcp:8080"] - networks: - - docx-network - restart: unless-stopped - healthcheck: - test: ["CMD", "nc", "-z", "localhost", "8080"] - interval: 30s - timeout: 5s - retries: 3 - - # Main docx-mcp server - docx-mcp: - image: docx-mcp:sandboxed - build: - context: . - dockerfile: Dockerfile.sandboxed - read_only: true - cap_drop: - - ALL - cap_add: - - NET_BIND_SERVICE # Only if exposing port for bridge - tmpfs: - - /tmp/docx-mcp:rw,noexec,nosuid,size=100m - deploy: - resources: - limits: - memory: 512M - cpus: '1.0' - replicas: 1 - environment: - - RUST_LOG=info - - DOCX_MCP_SANDBOX=true - - DOCX_MCP_NO_EXTERNAL_TOOLS=true - - DOCX_MCP_NO_NETWORK=true - - DOCX_MCP_MAX_SIZE=52428800 - - DOCX_MCP_MAX_DOCS=20 - networks: - - docx-network - ports: - - "8080:8080" # Only needed for WebSocket bridge - restart: unless-stopped - healthcheck: - test: ["CMD", "/usr/local/bin/docx-mcp", "--version"] - interval: 30s - timeout: 5s - retries: 3 - -networks: - docx-network: - driver: bridge diff --git a/download_fonts.sh b/download_fonts.sh deleted file mode 100755 index d38708f..0000000 --- a/download_fonts.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash - -# Script to download open-source fonts for embedded PDF generation -# These fonts are used when creating PDFs without external dependencies - -set -e - -FONTS_DIR="assets/fonts" -mkdir -p "$FONTS_DIR" - -echo "📥 Downloading open-source fonts for standalone operation..." - -# Liberation Fonts (Red Hat) - Open source replacements for Arial, Times New Roman, Courier -LIBERATION_VERSION="2.1.5" -LIBERATION_URL="https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-${LIBERATION_VERSION}.tar.gz" - -# Download Liberation fonts -echo "Downloading Liberation fonts..." -curl -L "$LIBERATION_URL" -o /tmp/liberation-fonts.tar.gz -tar -xzf /tmp/liberation-fonts.tar.gz -C /tmp/ - -# Copy the fonts we need -cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Regular.ttf" "$FONTS_DIR/" -cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Bold.ttf" "$FONTS_DIR/" -cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationSans-Italic.ttf" "$FONTS_DIR/" -cp "/tmp/liberation-fonts-ttf-${LIBERATION_VERSION}/LiberationMono-Regular.ttf" "$FONTS_DIR/" - -# Noto Sans (Google) - Fallback font with wide Unicode coverage -echo "Downloading Noto Sans fonts..." -NOTO_BASE_URL="https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans" - -curl -L "${NOTO_BASE_URL}/NotoSans-Regular.ttf" -o "$FONTS_DIR/NotoSans-Regular.ttf" -curl -L "${NOTO_BASE_URL}/NotoSans-Bold.ttf" -o "$FONTS_DIR/NotoSans-Bold.ttf" - -# Clean up -rm -f /tmp/liberation-fonts.tar.gz -rm -rf /tmp/liberation-fonts-ttf-${LIBERATION_VERSION} - -echo "✅ Fonts downloaded successfully!" -echo "" -echo "Fonts installed in $FONTS_DIR:" -ls -la "$FONTS_DIR"/*.ttf - -echo "" -echo "The application can now run completely standalone without external dependencies!" \ No newline at end of file diff --git a/example/MCP-Doc b/example/MCP-Doc deleted file mode 160000 index 377d05f..0000000 --- a/example/MCP-Doc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 377d05f0a94f1689544936200e89db0789bafb48 diff --git a/example/advanced_usage.md b/example/advanced_usage.md deleted file mode 100644 index 40d3477..0000000 --- a/example/advanced_usage.md +++ /dev/null @@ -1,492 +0,0 @@ -# Advanced DOCX MCP Server Usage Examples - -This document demonstrates the advanced capabilities of the DOCX MCP server with real-world examples. - -## Professional Document Templates - -### Creating a Business Report - -```javascript -// Ask your AI: "Create a professional quarterly report with our sales data" - -// 1. Create from report template -const doc = await mcp.call("create_from_template", { - template: "Report" -}); - -// 2. Set document properties -await mcp.call("set_document_properties", { - document_id: doc.document_id, - properties: { - title: "Q3 2024 Sales Report", - subject: "Quarterly Business Review", - author: "Sales Team", - company: "TechCorp Inc", - keywords: ["sales", "quarterly", "2024", "revenue"] - } -}); - -// 3. Add custom sections with advanced formatting -await mcp.call("add_section", { - document_id: doc.document_id, - section_config: { - page_size: "Letter", - landscape: false, - margins: { - top: 25.4, - bottom: 25.4, - left: 31.8, - right: 25.4 - }, - columns: 1 - } -}); - -// 4. Add charts and data visualization -await mcp.call("add_chart", { - document_id: doc.document_id, - chart_type: "Column", - data: { - title: "Quarterly Revenue Growth", - categories: ["Q1", "Q2", "Q3"], - series: [{ - name: "Revenue ($M)", - values: [1.2, 1.5, 1.8] - }] - } -}); -``` - -### Advanced Mail Merge Campaign - -```javascript -// Ask your AI: "Create personalized letters for our client list with custom fields" - -// 1. Create template with merge fields -const template = await mcp.call("create_from_template", { - template: "BusinessLetter" -}); - -await mcp.call("prepare_mail_merge_template", { - document_id: template.document_id, - fields: ["ClientName", "Company", "LastOrderDate", "AccountManager", "SpecialOffer"] -}); - -// 2. Process each recipient -const recipients = [ - { - ClientName: "John Smith", - Company: "ABC Corp", - LastOrderDate: "2024-02-15", - AccountManager: "Sarah Johnson", - SpecialOffer: "20% off next order" - } - // ... more recipients -]; - -for (const recipient of recipients) { - // Create personalized document - const personalDoc = await mcp.call("merge_template", { - template_id: template.document_id, - data: recipient - }); - - // Add watermark for draft review - await mcp.call("add_watermark", { - document_id: personalDoc.document_id, - text: "CONFIDENTIAL", - style: "Diagonal" - }); -} -``` - -## Document Analysis & Review - -### Comprehensive Document Analysis - -```javascript -// Ask your AI: "Analyze this contract for structure, formatting, and key terms" - -const doc = await mcp.call("open_document", { - path: "./contracts/service_agreement.docx" -}); - -// 1. Get document structure -const structure = await mcp.call("get_document_structure", { - document_id: doc.document_id -}); - -// 2. Analyze formatting consistency -const formatting = await mcp.call("analyze_formatting", { - document_id: doc.document_id -}); - -// 3. Get detailed statistics -const stats = await mcp.call("get_word_count", { - document_id: doc.document_id -}); - -// 4. Search for key legal terms -const terms = ["liability", "indemnification", "termination", "confidential"]; -for (const term of terms) { - const results = await mcp.call("search_text", { - document_id: doc.document_id, - search_term: term, - case_sensitive: false, - whole_word: true - }); - - console.log(`Found "${term}" ${results.total_matches} times`); -} - -// 5. Export analysis to Markdown -await mcp.call("export_to_markdown", { - document_id: doc.document_id, - output_path: "./analysis/contract_analysis.md" -}); -``` - -### Collaborative Review Process - -```javascript -// Ask your AI: "Set up this document for review with comments and track changes" - -// 1. Enable track changes -await mcp.call("enable_track_changes", { - document_id: doc.document_id, - author: "Legal Review Team" -}); - -// 2. Add review comments -await mcp.call("add_comment", { - document_id: doc.document_id, - text: "Payment terms in section 3.2", - comment: "Consider reducing payment terms from 60 to 30 days", - author: "Finance Team" -}); - -// 3. Add footnotes for clarification -await mcp.call("add_footnote", { - document_id: doc.document_id, - reference_text: "governing law", - footnote_text: "This clause should specify the state jurisdiction for legal disputes" -}); - -// 4. Create bookmarks for easy navigation -await mcp.call("add_bookmark", { - document_id: doc.document_id, - bookmark_name: "payment_terms", - text: "3.2 Payment Terms" -}); - -// 5. Add cross-references -await mcp.call("add_cross_reference", { - document_id: doc.document_id, - bookmark_name: "payment_terms", - display_text: "See Payment Terms section" -}); -``` - -## Security & Compliance Examples - -### Readonly Document Review - -```bash -# Start server in readonly mode for document review only -export DOCX_MCP_READONLY=true -./target/release/docx-mcp -``` - -```javascript -// In readonly mode, these operations are available: -const doc = await mcp.call("open_document", { - path: "./confidential/annual_report.docx" -}); - -// ✅ Allowed: Extract and analyze content -const text = await mcp.call("extract_text", { - document_id: doc.document_id -}); - -const structure = await mcp.call("get_document_structure", { - document_id: doc.document_id -}); - -// ✅ Allowed: Export for analysis -await mcp.call("export_to_markdown", { - document_id: doc.document_id, - output_path: "./analysis/report_content.md" -}); - -// ❌ Blocked: Any modification attempts -// These would return security errors: -// - add_paragraph -// - save_document -// - find_and_replace -``` - -### Sandboxed Environment - -```bash -# Run in sandbox mode - restricts file operations to temp directory -export DOCX_MCP_SANDBOX=true -export DOCX_MCP_NO_EXTERNAL_TOOLS=true -./target/release/docx-mcp -``` - -```javascript -// All file operations restricted to temporary directory -// Perfect for untrusted document processing - -const doc = await mcp.call("create_document", {}); - -// ✅ Allowed: Operations in temp directory -await mcp.call("save_document", { - document_id: doc.document_id, - output_path: "/tmp/docx-mcp/safe_output.docx" -}); - -// ❌ Blocked: Operations outside temp directory -// This would return a security error: -await mcp.call("save_document", { - document_id: doc.document_id, - output_path: "/home/user/documents/output.docx" // BLOCKED -}); -``` - -## Advanced Automation Workflows - -### Automated Report Generation Pipeline - -```javascript -// Ask your AI: "Create an automated monthly report generation system" - -class ReportGenerator { - async generateMonthlyReport(month, year, data) { - // 1. Create from template - const doc = await mcp.call("create_from_template", { - template: "Report" - }); - - // 2. Set up custom styles - await mcp.call("add_custom_style", { - document_id: doc.document_id, - style: { - id: "CompanyHeading", - name: "Company Heading", - font: "Arial", - size: 18, - bold: true, - color: "#2E86C1", - spacing: { - before: 12, - after: 6, - line: 1.15 - } - } - }); - - // 3. Add dynamic content with bookmarks - await mcp.call("add_bookmark", { - document_id: doc.document_id, - bookmark_name: "executive_summary", - text: "Executive Summary" - }); - - // 4. Insert data charts - for (const metric of data.metrics) { - await mcp.call("add_chart", { - document_id: doc.document_id, - chart_type: metric.type, - data: { - title: metric.title, - categories: metric.categories, - series: metric.series - } - }); - } - - // 5. Add table of contents - await mcp.call("add_table_of_contents", { - document_id: doc.document_id - }); - - // 6. Apply watermark - await mcp.call("add_watermark", { - document_id: doc.document_id, - text: "INTERNAL USE ONLY", - style: "Horizontal" - }); - - // 7. Generate multiple formats - const filename = `monthly_report_${year}_${month}`; - - // Save DOCX - await mcp.call("save_document", { - document_id: doc.document_id, - output_path: `./reports/${filename}.docx` - }); - - // Convert to PDF - await mcp.call("convert_to_pdf", { - document_id: doc.document_id, - output_path: `./reports/${filename}.pdf` - }); - - // Generate preview images - await mcp.call("convert_to_images", { - document_id: doc.document_id, - output_dir: `./reports/previews/`, - format: "png", - dpi: 150 - }); - - return { - docx: `./reports/${filename}.docx`, - pdf: `./reports/${filename}.pdf`, - preview: `./reports/previews/` - }; - } -} -``` - -### Document Quality Assurance - -```javascript -// Ask your AI: "Create a document QA system that checks formatting and compliance" - -class DocumentQA { - async auditDocument(documentPath) { - const doc = await mcp.call("open_document", { - path: documentPath - }); - - const audit = { - document: documentPath, - timestamp: new Date().toISOString(), - issues: [], - recommendations: [] - }; - - // 1. Check document structure - const structure = await mcp.call("get_document_structure", { - document_id: doc.document_id - }); - - if (structure.structure.filter(s => s.type === "heading").length < 2) { - audit.issues.push("Document lacks proper heading structure"); - } - - // 2. Analyze formatting consistency - const formatting = await mcp.call("analyze_formatting", { - document_id: doc.document_id - }); - - if (formatting.formatting_analysis.fonts_detected.length > 3) { - audit.issues.push("Too many fonts used - limit to 2-3 for consistency"); - } - - // 3. Check for required content - const requiredTerms = ["confidential", "copyright", "contact"]; - for (const term of requiredTerms) { - const search = await mcp.call("search_text", { - document_id: doc.document_id, - search_term: term, - case_sensitive: false - }); - - if (search.total_matches === 0) { - audit.recommendations.push(`Consider adding ${term} information`); - } - } - - // 4. Check document statistics - const stats = await mcp.call("get_word_count", { - document_id: doc.document_id - }); - - if (stats.statistics.words < 500) { - audit.issues.push("Document may be too short for professional standards"); - } - - // 5. Generate audit report - const auditDoc = await mcp.call("create_document", {}); - - await mcp.call("add_heading", { - document_id: auditDoc.document_id, - text: "Document Quality Audit Report", - level: 1 - }); - - await mcp.call("add_paragraph", { - document_id: auditDoc.document_id, - text: `Audit completed for: ${documentPath}` - }); - - // Add issues table - const issuesData = audit.issues.map(issue => ["Issue", issue]); - await mcp.call("add_table", { - document_id: auditDoc.document_id, - rows: [["Type", "Description"], ...issuesData] - }); - - await mcp.call("save_document", { - document_id: auditDoc.document_id, - output_path: `./qa/audit_${Date.now()}.docx` - }); - - return audit; - } -} -``` - -## Security Configuration Examples - -### Enterprise Security Setup - -```bash -#!/bin/bash -# Enterprise security configuration script - -# Readonly mode for document review workstations -export DOCX_MCP_READONLY=true - -# Whitelist only analysis and export commands -export DOCX_MCP_WHITELIST="open_document,extract_text,get_metadata,get_document_structure,analyze_formatting,get_word_count,search_text,export_to_markdown,export_to_html,list_documents,get_security_info" - -# Sandbox mode for processing untrusted documents -export DOCX_MCP_SANDBOX=true - -# Resource limits -export DOCX_MCP_MAX_SIZE=10485760 # 10MB max file size -export DOCX_MCP_MAX_DOCS=5 # Max 5 open documents - -# Disable external tools and network -export DOCX_MCP_NO_EXTERNAL_TOOLS=true -export DOCX_MCP_NO_NETWORK=true - -echo "🔒 Starting DOCX MCP Server in Enterprise Security Mode" -./target/release/docx-mcp -``` - -### Development Environment Setup - -```bash -#!/bin/bash -# Development environment with full features - -# Allow all operations but with reasonable limits -export DOCX_MCP_MAX_SIZE=104857600 # 100MB max file size -export DOCX_MCP_MAX_DOCS=25 # Max 25 open documents - -# Enable all features -unset DOCX_MCP_READONLY -unset DOCX_MCP_SANDBOX -unset DOCX_MCP_WHITELIST -unset DOCX_MCP_BLACKLIST - -echo "🚀 Starting DOCX MCP Server in Development Mode" -./target/release/docx-mcp -``` - -These examples demonstrate the full power and flexibility of the DOCX MCP server for professional document workflows, from simple document creation to complex enterprise automation systems. \ No newline at end of file diff --git a/example/automation_example.py b/example/automation_example.py deleted file mode 100644 index a45d1e7..0000000 --- a/example/automation_example.py +++ /dev/null @@ -1,503 +0,0 @@ -#!/usr/bin/env python3 -""" -Advanced automation example using the DOCX MCP Server. -This demonstrates how to build document automation workflows. -""" - -import json -import asyncio -import csv -from datetime import datetime -from pathlib import Path -from typing import List, Dict, Any - -# This would normally be your MCP client library -# For demonstration, we're showing the structure -class MCPClient: - """Mock MCP Client for demonstration""" - - async def call(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: - """Call an MCP tool""" - # In reality, this would communicate with the MCP server - print(f"Calling {tool_name} with {arguments}") - return {"success": True, "result": {}} - -# Initialize client -mcp = MCPClient() - -# === Example 1: Generate Monthly Reports === -async def generate_monthly_report(month: int, year: int, data: Dict[str, Any]): - """Generate a comprehensive monthly report""" - - # Create new document - doc = await mcp.call("create_document", {}) - doc_id = doc["result"]["document_id"] - - # Add title page - await mcp.call("add_heading", { - "document_id": doc_id, - "text": f"{data['company_name']}", - "level": 1 - }) - - await mcp.call("add_heading", { - "document_id": doc_id, - "text": f"Monthly Report - {datetime(year, month, 1).strftime('%B %Y')}", - "level": 2 - }) - - await mcp.call("add_page_break", {"document_id": doc_id}) - - # Executive Summary - await mcp.call("add_heading", { - "document_id": doc_id, - "text": "Executive Summary", - "level": 1 - }) - - await mcp.call("add_paragraph", { - "document_id": doc_id, - "text": data["executive_summary"], - "style": { - "font_size": 12, - "alignment": "justify" - } - }) - - # Key Metrics Table - await mcp.call("add_heading", { - "document_id": doc_id, - "text": "Key Performance Indicators", - "level": 2 - }) - - await mcp.call("add_table", { - "document_id": doc_id, - "rows": [ - ["Metric", "Target", "Actual", "Variance"], - ["Revenue", f"${data['targets']['revenue']:,}", f"${data['actuals']['revenue']:,}", - f"{((data['actuals']['revenue'] / data['targets']['revenue'] - 1) * 100):.1f}%"], - ["New Customers", str(data['targets']['customers']), str(data['actuals']['customers']), - f"{data['actuals']['customers'] - data['targets']['customers']:+d}"], - ["Satisfaction Score", f"{data['targets']['satisfaction']}%", f"{data['actuals']['satisfaction']}%", - f"{data['actuals']['satisfaction'] - data['targets']['satisfaction']:+.1f}%"] - ] - }) - - # Department Reports - for dept in data['departments']: - await mcp.call("add_heading", { - "document_id": doc_id, - "text": f"{dept['name']} Department", - "level": 2 - }) - - await mcp.call("add_paragraph", { - "document_id": doc_id, - "text": dept['summary'] - }) - - if dept.get('achievements'): - await mcp.call("add_list", { - "document_id": doc_id, - "items": dept['achievements'], - "ordered": False - }) - - # Action Items - await mcp.call("add_heading", { - "document_id": doc_id, - "text": "Action Items for Next Month", - "level": 2 - }) - - await mcp.call("add_table", { - "document_id": doc_id, - "rows": [ - ["Action", "Owner", "Due Date", "Priority"], - *[[item['action'], item['owner'], item['due_date'], item['priority']] - for item in data['action_items']] - ] - }) - - # Add footer - await mcp.call("set_footer", { - "document_id": doc_id, - "text": f"Confidential - {data['company_name']} - Page" - }) - - # Save as DOCX - filename = f"monthly_report_{year}_{month:02d}.docx" - await mcp.call("save_document", { - "document_id": doc_id, - "output_path": f"./reports/{filename}" - }) - - # Convert to PDF - await mcp.call("convert_to_pdf", { - "document_id": doc_id, - "output_path": f"./reports/{filename.replace('.docx', '.pdf')}" - }) - - # Generate thumbnail - await mcp.call("convert_to_images", { - "document_id": doc_id, - "output_dir": "./reports/thumbnails/", - "format": "png", - "dpi": 72 - }) - - await mcp.call("close_document", {"document_id": doc_id}) - - return filename - -# === Example 2: Mail Merge === -async def mail_merge(template_path: str, csv_path: str, output_dir: str): - """Perform mail merge with CSV data""" - - # Read CSV data - with open(csv_path, 'r') as f: - reader = csv.DictReader(f) - recipients = list(reader) - - generated_files = [] - - for recipient in recipients: - # Open template - template = await mcp.call("open_document", {"path": template_path}) - doc_id = template["result"]["document_id"] - - # Extract template text - text_result = await mcp.call("extract_text", {"document_id": doc_id}) - text = text_result["result"]["text"] - - # Replace placeholders - for field, value in recipient.items(): - placeholder = f"{{{{{field}}}}}" - if placeholder in text: - await mcp.call("find_and_replace", { - "document_id": doc_id, - "find_text": placeholder, - "replace_text": value - }) - - # Save personalized document - output_filename = f"{recipient.get('name', 'document').replace(' ', '_')}.docx" - output_path = f"{output_dir}/{output_filename}" - - await mcp.call("save_document", { - "document_id": doc_id, - "output_path": output_path - }) - - # Convert to PDF - pdf_path = output_path.replace('.docx', '.pdf') - await mcp.call("convert_to_pdf", { - "document_id": doc_id, - "output_path": pdf_path - }) - - generated_files.append({ - "recipient": recipient['name'], - "docx": output_path, - "pdf": pdf_path - }) - - await mcp.call("close_document", {"document_id": doc_id}) - - # Create summary document - summary = await mcp.call("create_document", {}) - summary_id = summary["result"]["document_id"] - - await mcp.call("add_heading", { - "document_id": summary_id, - "text": "Mail Merge Summary", - "level": 1 - }) - - await mcp.call("add_paragraph", { - "document_id": summary_id, - "text": f"Generated {len(generated_files)} documents on {datetime.now().strftime('%Y-%m-%d %H:%M')}" - }) - - # Add summary table - rows = [["Recipient", "DOCX File", "PDF File"]] - for file_info in generated_files: - rows.append([ - file_info['recipient'], - file_info['docx'], - file_info['pdf'] - ]) - - await mcp.call("add_table", { - "document_id": summary_id, - "rows": rows - }) - - await mcp.call("save_document", { - "document_id": summary_id, - "output_path": f"{output_dir}/merge_summary.docx" - }) - - await mcp.call("close_document", {"document_id": summary_id}) - - return generated_files - -# === Example 3: Document Pipeline === -async def document_processing_pipeline(input_dir: str): - """Process multiple documents through a pipeline""" - - input_path = Path(input_dir) - docx_files = list(input_path.glob("*.docx")) - - results = [] - - for docx_file in docx_files: - print(f"Processing {docx_file.name}...") - - # Open document - doc = await mcp.call("open_document", {"path": str(docx_file)}) - doc_id = doc["result"]["document_id"] - - # Add watermark (header) - await mcp.call("set_header", { - "document_id": doc_id, - "text": "DRAFT - CONFIDENTIAL" - }) - - # Add footer with date - await mcp.call("set_footer", { - "document_id": doc_id, - "text": f"Processed on {datetime.now().strftime('%Y-%m-%d')}" - }) - - # Extract text for indexing - text_result = await mcp.call("extract_text", {"document_id": doc_id}) - text = text_result["result"]["text"] - word_count = len(text.split()) - - # Save modified document - output_docx = f"./processed/{docx_file.stem}_processed.docx" - await mcp.call("save_document", { - "document_id": doc_id, - "output_path": output_docx - }) - - # Convert to PDF - output_pdf = output_docx.replace('.docx', '.pdf') - await mcp.call("convert_to_pdf", { - "document_id": doc_id, - "output_path": output_pdf - }) - - # Generate thumbnail - await mcp.call("convert_to_images", { - "document_id": doc_id, - "output_dir": "./processed/thumbnails/", - "format": "jpg", - "dpi": 96 - }) - - results.append({ - "original": docx_file.name, - "word_count": word_count, - "docx": output_docx, - "pdf": output_pdf - }) - - await mcp.call("close_document", {"document_id": doc_id}) - - # Create index document - index = await mcp.call("create_document", {}) - index_id = index["result"]["document_id"] - - await mcp.call("add_heading", { - "document_id": index_id, - "text": "Document Processing Report", - "level": 1 - }) - - await mcp.call("add_paragraph", { - "document_id": index_id, - "text": f"Processed {len(results)} documents" - }) - - # Statistics table - rows = [["Original File", "Word Count", "Output DOCX", "Output PDF"]] - for result in results: - rows.append([ - result['original'], - str(result['word_count']), - result['docx'], - result['pdf'] - ]) - - await mcp.call("add_table", { - "document_id": index_id, - "rows": rows - }) - - await mcp.call("save_document", { - "document_id": index_id, - "output_path": "./processed/index.docx" - }) - - await mcp.call("close_document", {"document_id": index_id}) - - return results - -# === Example 4: Contract Generator === -async def generate_contract(contract_type: str, parties: Dict[str, Any], terms: Dict[str, Any]): - """Generate a legal contract based on type and terms""" - - doc = await mcp.call("create_document", {}) - doc_id = doc["result"]["document_id"] - - # Title - await mcp.call("add_heading", { - "document_id": doc_id, - "text": f"{contract_type.upper()} AGREEMENT", - "level": 1 - }) - - # Date and parties - await mcp.call("add_paragraph", { - "document_id": doc_id, - "text": f"This Agreement is entered into as of {terms['date']} between:" - }) - - await mcp.call("add_list", { - "document_id": doc_id, - "items": [ - f"{parties['party1']['name']}, a {parties['party1']['type']} (\"Party 1\")", - f"{parties['party2']['name']}, a {parties['party2']['type']} (\"Party 2\")" - ], - "ordered": False - }) - - # Terms sections - section_num = 1 - for section_title, section_content in terms['sections'].items(): - await mcp.call("add_heading", { - "document_id": doc_id, - "text": f"{section_num}. {section_title}", - "level": 2 - }) - - if isinstance(section_content, list): - await mcp.call("add_list", { - "document_id": doc_id, - "items": section_content, - "ordered": True - }) - else: - await mcp.call("add_paragraph", { - "document_id": doc_id, - "text": section_content - }) - - section_num += 1 - - # Signature block - await mcp.call("add_page_break", {"document_id": doc_id}) - await mcp.call("add_heading", { - "document_id": doc_id, - "text": "SIGNATURES", - "level": 2 - }) - - signature_table = [ - ["Party 1:", "", "Party 2:", ""], - ["", "", "", ""], - ["_" * 30, "", "_" * 30, ""], - ["Name:", parties['party1']['signatory'], "Name:", parties['party2']['signatory']], - ["Title:", parties['party1']['title'], "Title:", parties['party2']['title']], - ["Date:", "_" * 20, "Date:", "_" * 20] - ] - - await mcp.call("add_table", { - "document_id": doc_id, - "rows": signature_table - }) - - # Save and convert - filename = f"{contract_type.lower().replace(' ', '_')}_{datetime.now().strftime('%Y%m%d')}" - await mcp.call("save_document", { - "document_id": doc_id, - "output_path": f"./contracts/{filename}.docx" - }) - - await mcp.call("convert_to_pdf", { - "document_id": doc_id, - "output_path": f"./contracts/{filename}.pdf" - }) - - await mcp.call("close_document", {"document_id": doc_id}) - - return filename - -# === Main execution === -async def main(): - """Run example automations""" - - print("Document Automation Examples") - print("=" * 40) - - # Example data for monthly report - report_data = { - "company_name": "TechCorp Industries", - "executive_summary": "This month showed strong growth across all departments...", - "targets": {"revenue": 1000000, "customers": 50, "satisfaction": 85}, - "actuals": {"revenue": 1150000, "customers": 62, "satisfaction": 88.5}, - "departments": [ - { - "name": "Sales", - "summary": "Sales exceeded targets by 15%", - "achievements": ["Closed 3 enterprise deals", "Expanded into new market"] - }, - { - "name": "Engineering", - "summary": "Delivered 2 major features on schedule", - "achievements": ["Reduced bug count by 30%", "Improved performance by 25%"] - } - ], - "action_items": [ - {"action": "Hire 2 senior developers", "owner": "HR", "due_date": "2024-02-15", "priority": "High"}, - {"action": "Launch marketing campaign", "owner": "Marketing", "due_date": "2024-02-01", "priority": "Medium"} - ] - } - - # Generate monthly report - print("\n1. Generating monthly report...") - report_file = await generate_monthly_report(1, 2024, report_data) - print(f" ✓ Generated: {report_file}") - - # Contract generation - print("\n2. Generating service agreement...") - contract_file = await generate_contract( - "Service Agreement", - { - "party1": {"name": "ABC Corp", "type": "corporation", "signatory": "John Smith", "title": "CEO"}, - "party2": {"name": "XYZ Ltd", "type": "limited company", "signatory": "Jane Doe", "title": "Director"} - }, - { - "date": "January 15, 2024", - "sections": { - "Scope of Services": "Party 2 agrees to provide consulting services...", - "Payment Terms": ["Monthly fee of $10,000", "Payment due within 30 days", "Late fee of 1.5% per month"], - "Term and Termination": "This agreement shall commence on the date first written above...", - "Confidentiality": "Both parties agree to maintain strict confidentiality..." - } - } - ) - print(f" ✓ Generated: {contract_file}") - - print("\n✅ All automation examples completed!") - -if __name__ == "__main__": - # Create necessary directories - for dir_path in ["./reports", "./reports/thumbnails", "./contracts", "./processed", "./processed/thumbnails"]: - Path(dir_path).mkdir(parents=True, exist_ok=True) - - # Run examples - asyncio.run(main()) \ No newline at end of file diff --git a/example/claude_examples.md b/example/claude_examples.md deleted file mode 100644 index 7666877..0000000 --- a/example/claude_examples.md +++ /dev/null @@ -1,166 +0,0 @@ -# Claude Desktop Examples - -These are real examples you can use with Claude Desktop once the DOCX MCP server is configured. - -## Basic Document Creation - -``` -You: Create a new Word document with a professional letterhead for "Acme Corp" and save it as letterhead.docx -``` - -Claude will create a document with: -- Company name as heading -- Address and contact details -- Professional formatting -- Save to the specified file - -## Invoice Generation - -``` -You: Generate an invoice for client "TechStart Inc" with these items: -- 10 hours consulting at $150/hour -- 1 software license at $500 -- Add 10% tax -Save as invoice_2024_001.docx and convert to PDF -``` - -## Batch Processing - -``` -You: I have 5 DOCX files in the ./reports folder. Please: -1. Add page numbers to each -2. Set the header to "Confidential - Internal Use Only" -3. Convert all to PDF -4. Create a summary document listing all reports -``` - -## Data-Driven Documents - -``` -You: Create a sales report from this data: -Q1: $1.2M (15% growth) -Q2: $1.5M (25% growth) -Q3: $1.3M (8% growth) -Q4: $1.8M (38% growth) - -Include: -- Executive summary -- Quarterly breakdown table -- Year-over-year comparison -- Recommendations section -Convert to PDF when done -``` - -## Template Operations - -``` -You: Open template.docx and replace these placeholders: -- {{CLIENT_NAME}} with "John Smith" -- {{DATE}} with today's date -- {{PROJECT}} with "Website Redesign" -- {{AMOUNT}} with "$5,000" -Save as contract_john_smith.docx -``` - -## Document Merging - -``` -You: Merge these documents in order: -1. cover_page.docx -2. executive_summary.docx -3. main_report.docx -4. appendix.docx - -Add page numbers and a table of contents, then save as final_report.docx -``` - -## Text Extraction and Analysis - -``` -You: Extract all text from the documents in ./legal folder and: -1. Find all mentions of "liability" -2. Create a summary document with each mention and its context -3. Add a table showing which document contains which terms -``` - -## Report Formatting - -``` -You: Format this markdown content as a professional Word document: - -# Project Status Report -## Overview -Project is on track... -## Milestones -- [x] Phase 1 complete -- [ ] Phase 2 in progress -## Budget -Current spend: $45,000 of $100,000 - -Add proper styling, convert checkboxes to a status table, and export as PDF. -``` - -## Document Comparison - -``` -You: Open contract_v1.docx and contract_v2.docx, then: -1. Extract text from both -2. Create a new document highlighting the differences -3. Add a summary table of all changes -4. Save as contract_comparison.docx -``` - -## Automated Documentation - -``` -You: Create API documentation from this OpenAPI spec file (api.yaml): -1. Generate a Word document with proper formatting -2. Include endpoint descriptions in a table -3. Add request/response examples -4. Create a PDF version for distribution -``` - -## Meeting Minutes Template - -``` -You: Create a meeting minutes template with: -- Company header -- Date, time, attendees fields -- Agenda items section -- Action items table with owner and due date columns -- Next meeting section -Save as meeting_template.docx -``` - -## Bulk Conversion - -``` -You: Convert all Word documents in my Downloads folder to: -1. PDF files in ./pdfs folder -2. PNG images (first page only) in ./thumbnails folder -3. Create an index.docx with links to all documents -``` - -## Complex Formatting - -``` -You: Create a technical specification document with: -1. Title page with document version and date -2. Table of contents (auto-generated) -3. Multiple heading levels -4. Code blocks with syntax highlighting effect -5. Diagrams placeholder sections -6. Numbered requirements list -7. Glossary table at the end -8. Footer with page numbers -``` - -## Mail Merge Simulation - -``` -You: I have a CSV with client data (clients.csv). For each client: -1. Create a personalized letter using template.docx -2. Replace all placeholders with client data -3. Save as PDF with client name in filename -4. Create a summary document listing all generated files -``` \ No newline at end of file diff --git a/example/config_examples.json b/example/config_examples.json deleted file mode 100644 index c30438e..0000000 --- a/example/config_examples.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "claude_desktop": { - "description": "Claude Desktop configuration", - "file_location_macos": "~/Library/Application Support/Claude/claude_desktop_config.json", - "file_location_windows": "%APPDATA%\\Claude\\claude_desktop_config.json", - "config": { - "mcpServers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - }, - "cursor": { - "description": "Cursor IDE configuration", - "file_location": "~/.cursor/config.json or Settings UI", - "config": { - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - } - }, - "windsurf": { - "description": "Windsurf (Codeium) configuration", - "file_location": "~/.windsurf/config.json", - "config": { - "mcp": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - } - }, - "continue_dev": { - "description": "Continue.dev configuration", - "file_location": "~/.continue/config.json", - "config": { - "models": [ - { - "title": "Claude 3.5 Sonnet", - "provider": "anthropic", - "model": "claude-3-5-sonnet-20241022", - "apiKey": "your-api-key", - "mcp_servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [] - } - } - } - ] - } - }, - "vscode_mcp": { - "description": "VS Code with MCP Extension", - "file_location": ".vscode/settings.json", - "config": { - "mcp.servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - }, - "zed": { - "description": "Zed editor configuration", - "file_location": "~/.config/zed/settings.json", - "config": { - "assistant": { - "mcp_servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - } - }, - "neovim": { - "description": "Neovim with MCP support", - "file_location": "~/.config/nvim/mcp.json", - "config": { - "servers": { - "docx": { - "command": "/absolute/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - } - } - } - }, - "multiple_servers_example": { - "description": "Example with multiple MCP servers", - "config": { - "mcpServers": { - "docx": { - "command": "/path/to/docx-mcp/target/release/docx-mcp", - "args": [], - "env": { - "RUST_LOG": "info" - } - }, - "filesystem": { - "command": "/path/to/filesystem-mcp", - "args": ["--root", "/home/user/documents"] - }, - "github": { - "command": "/path/to/github-mcp", - "args": [], - "env": { - "GITHUB_TOKEN": "ghp_..." - } - } - } - } - } -} \ No newline at end of file diff --git a/example/test_client.py b/example/test_client.py deleted file mode 100755 index 2bc1baa..0000000 --- a/example/test_client.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -Example client to test the DOCX MCP Server. -This demonstrates how to interact with the server using JSON-RPC. -""" - -import json -import sys -import asyncio -import websockets - -async def call_tool(websocket, tool_name, arguments): - """Call a tool on the MCP server""" - request = { - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments - }, - "id": 1 - } - - await websocket.send(json.dumps(request)) - response = await websocket.recv() - return json.loads(response) - -async def main(): - # Connect to the MCP server (adjust the URI as needed) - uri = "ws://localhost:3000" # Default MCP server port - - async with websockets.connect(uri) as websocket: - print("Connected to DOCX MCP Server") - - # Example 1: Create a new document - print("\n1. Creating new document...") - result = await call_tool(websocket, "create_document", {}) - doc_id = result["result"]["document_id"] - print(f" Document created with ID: {doc_id}") - - # Example 2: Add a heading - print("\n2. Adding heading...") - result = await call_tool(websocket, "add_heading", { - "document_id": doc_id, - "text": "Sample Document", - "level": 1 - }) - print(" Heading added") - - # Example 3: Add a paragraph with styling - print("\n3. Adding styled paragraph...") - result = await call_tool(websocket, "add_paragraph", { - "document_id": doc_id, - "text": "This is a sample paragraph with custom styling.", - "style": { - "font_size": 14, - "bold": True, - "color": "#0066CC", - "alignment": "center" - } - }) - print(" Styled paragraph added") - - # Example 4: Add a table - print("\n4. Adding table...") - result = await call_tool(websocket, "add_table", { - "document_id": doc_id, - "rows": [ - ["Product", "Price", "Quantity"], - ["Widget A", "$10.99", "100"], - ["Widget B", "$15.99", "75"], - ["Widget C", "$8.99", "150"] - ] - }) - print(" Table added") - - # Example 5: Add a numbered list - print("\n5. Adding numbered list...") - result = await call_tool(websocket, "add_list", { - "document_id": doc_id, - "items": [ - "First item in the list", - "Second item with more text", - "Third and final item" - ], - "ordered": True - }) - print(" Numbered list added") - - # Example 6: Set header and footer - print("\n6. Setting header and footer...") - result = await call_tool(websocket, "set_header", { - "document_id": doc_id, - "text": "Sample Document Header" - }) - result = await call_tool(websocket, "set_footer", { - "document_id": doc_id, - "text": "Page 1 | Confidential" - }) - print(" Header and footer set") - - # Example 7: Save the document - print("\n7. Saving document...") - result = await call_tool(websocket, "save_document", { - "document_id": doc_id, - "output_path": "./sample_output.docx" - }) - print(" Document saved to sample_output.docx") - - # Example 8: Convert to PDF - print("\n8. Converting to PDF...") - result = await call_tool(websocket, "convert_to_pdf", { - "document_id": doc_id, - "output_path": "./sample_output.pdf" - }) - if result["result"]["success"]: - print(" Document converted to PDF") - else: - print(f" PDF conversion failed: {result['result'].get('error', 'Unknown error')}") - - # Example 9: Convert to images - print("\n9. Converting to images...") - result = await call_tool(websocket, "convert_to_images", { - "document_id": doc_id, - "output_dir": "./images/", - "format": "png", - "dpi": 150 - }) - if result["result"]["success"]: - print(f" Document converted to images: {result['result']['images']}") - else: - print(f" Image conversion failed: {result['result'].get('error', 'Unknown error')}") - - # Example 10: Extract text - print("\n10. Extracting text...") - result = await call_tool(websocket, "extract_text", { - "document_id": doc_id - }) - text = result["result"]["text"] - print(f" Extracted text (first 100 chars): {text[:100]}...") - - # Example 11: Get metadata - print("\n11. Getting metadata...") - result = await call_tool(websocket, "get_metadata", { - "document_id": doc_id - }) - metadata = result["result"]["metadata"] - print(f" Document metadata: {json.dumps(metadata, indent=2)}") - - # Example 12: Close the document - print("\n12. Closing document...") - result = await call_tool(websocket, "close_document", { - "document_id": doc_id - }) - print(" Document closed") - - print("\n✅ All tests completed successfully!") - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/justfile b/justfile deleted file mode 100644 index a480816..0000000 --- a/justfile +++ /dev/null @@ -1,476 +0,0 @@ -# Justfile for docx-mcp project -# Usage: just -# Install just: https://github.com/casey/just - -# Default recipe -default: - @just --list - -# Build the project -build: - cargo build --all-features - -# Build for release -build-release: - cargo build --release --all-features - -# Run all tests -test: - ./scripts/run_tests.sh - -# Run only unit tests -test-unit: - ./scripts/run_tests.sh --unit-only - -# Run only integration tests -test-integration: - ./scripts/run_tests.sh --integration-only - -# Run all tests including slow ones -test-all: - ./scripts/run_tests.sh --all - -# Run performance tests -test-performance: - ./scripts/run_tests.sh --performance - -# Generate coverage report -coverage: - ./scripts/run_tests.sh --coverage - -# Run benchmarks -bench: - cargo bench --all-features - -# Check code formatting -fmt-check: - cargo fmt --all -- --check - -# Format code -fmt: - cargo fmt --all - -# Run Clippy lints -clippy: - cargo clippy --all-targets --all-features -- -D warnings - -# Fix Clippy issues automatically where possible -clippy-fix: - cargo clippy --all-targets --all-features --fix - -# Run security audit -audit: - cargo audit - -# Check dependencies for issues -deny: - cargo deny check - -# Clean build artifacts -clean: - cargo clean - -# Update dependencies -update: - cargo update - -# Install development tools -install-dev-tools: - cargo install cargo-audit cargo-deny cargo-llvm-cov - -# Run the application in development mode -dev: - RUST_LOG=debug cargo run --all-features - -# Run the application in release mode -run: - cargo run --release --all-features - -# Generate documentation -docs: - cargo doc --all-features --no-deps --open - -# Check documentation -docs-check: - cargo doc --all-features --no-deps - -# Package the project for distribution -package: - cargo package - -# Publish to crates.io (dry run) -publish-dry: - cargo publish --dry-run - -# Publish to crates.io -publish: - cargo publish - -# Docker build -docker-build: - docker build -t docx-mcp:latest . - -# Docker run -docker-run: - docker run -p 8080:8080 docx-mcp:latest - -# Run pre-commit checks (formatting, linting, tests) -pre-commit: fmt-check clippy test-unit - -# Full CI pipeline simulation -ci: pre-commit test audit - -# Quick development cycle (format, build, test) -dev-cycle: fmt build test-unit - -# Setup development environment -setup: - rustup component add rustfmt clippy llvm-tools-preview - just install-dev-tools - -# Generate sample documents for testing -generate-samples: - cargo run --bin generate-test-docs --features=test-utils - -# Run stress tests -stress-test: - STRESS_TEST=1 cargo test --release --test performance_tests -- --ignored --test-threads=1 - -# Profile the application -profile: - cargo build --release --all-features - perf record -g target/release/docx-mcp - perf report - -# Memory usage analysis -memory-check: - cargo build --all-features - valgrind --tool=memcheck --leak-check=full target/debug/docx-mcp - -# Run with different Rust versions (requires rustup) -test-msrv: - rustup install 1.70.0 - rustup run 1.70.0 cargo test - -# Check for outdated dependencies -outdated: - cargo install cargo-outdated - cargo outdated - -# Security scan -security-scan: audit deny - -# Performance profiling with flamegraph -flamegraph: - cargo install flamegraph - cargo flamegraph --bin docx-mcp - -# Generate changelog (requires git-cliff) -changelog: - git cliff --output CHANGELOG.md - -# Release commands using the release script - -# Create a patch release (0.1.0 -> 0.1.1) -release-patch: - ./scripts/release.sh patch - -# Create a minor release (0.1.0 -> 0.2.0) -release-minor: - ./scripts/release.sh minor - -# Create a major release (0.1.0 -> 1.0.0) -release-major: - ./scripts/release.sh major - -# Create a specific version release -release-version version: - ./scripts/release.sh version {{version}} - -# Dry run of patch release (see what would happen) -release-patch-dry: - ./scripts/release.sh patch --dry-run - -# Dry run of minor release -release-minor-dry: - ./scripts/release.sh minor --dry-run - -# Dry run of major release -release-major-dry: - ./scripts/release.sh major --dry-run - -# Dry run of specific version release -release-version-dry version: - ./scripts/release.sh version {{version}} --dry-run - -# Run all pre-release checks -release-check: - ./scripts/release.sh check - -# Generate changelog since last tag -release-changelog: - ./scripts/release.sh changelog - -# Create git tag for current version -release-tag: - ./scripts/release.sh tag - -# Prepare a release (legacy command - use release-* commands above) -prepare-release version: - @echo "⚠️ This command is deprecated. Use 'just release-version {{version}}' instead." - @echo "The new release commands provide better automation and safety checks." - @echo "" - @echo "Available release commands:" - @echo " just release-patch - Bump patch version" - @echo " just release-minor - Bump minor version" - @echo " just release-major - Bump major version" - @echo " just release-version X.Y.Z - Set specific version" - -# Show project statistics -stats: - @echo "=== Project Statistics ===" - @echo "Lines of code:" - @find src -name "*.rs" -type f -exec wc -l {} + | tail -n 1 - @echo "" - @echo "Test coverage:" - @just coverage --quiet | grep "Overall coverage" || echo "Run 'just coverage' first" - @echo "" - @echo "Dependencies:" - @cargo tree --depth 1 | wc -l - @echo "" - @echo "Binary size (release):" - @if [ -f "target/release/docx-mcp" ]; then ls -lh target/release/docx-mcp | awk '{print $5}'; else echo "Run 'just build-release' first"; fi - -# Watch for changes and run tests -watch: - cargo install cargo-watch - cargo watch -x "test --lib" - -# Watch for changes and run specific test -watch-test test_name: - cargo watch -x "test {{test_name}}" - -# Initialize git hooks -init-hooks: - #!/usr/bin/env bash - cat > .git/hooks/pre-commit << 'EOF' - #!/bin/bash - just pre-commit - EOF - chmod +x .git/hooks/pre-commit - echo "Pre-commit hook installed" - -# Remove git hooks -remove-hooks: - rm -f .git/hooks/pre-commit - echo "Pre-commit hook removed" - -# Docker commands - -# Build multi-platform Docker image -docker-build-multiarch: - docker buildx create --use --name multiarch || true - docker buildx build --platform linux/amd64,linux/arm64 -t docx-mcp:latest . - -# Build and tag Docker image for release -docker-build-release version: - docker buildx build --platform linux/amd64,linux/arm64 \ - -t docx-mcp:{{version}} \ - -t docx-mcp:latest \ - -t ghcr.io/hongkongkiwi/docx-mcp:{{version}} \ - -t ghcr.io/hongkongkiwi/docx-mcp:latest \ - . - -# Push Docker images to registry -docker-push version: - docker push docx-mcp:{{version}} - docker push docx-mcp:latest - docker push ghcr.io/hongkongkiwi/docx-mcp:{{version}} - docker push ghcr.io/hongkongkiwi/docx-mcp:latest - -# Run Docker container with volume mount for testing -docker-test: - docker run --rm -it -v $(pwd)/test-docs:/test-docs docx-mcp:latest - -# Development environment commands - -# Full development setup from scratch -dev-setup: - # Install Rust if not present - @if ! command -v rustup >/dev/null 2>&1; then \ - echo "Installing Rust..."; \ - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y; \ - source ~/.cargo/env; \ - fi - # Setup toolchain and tools - just setup - # Initialize git hooks - just init-hooks - # Build project - just build - echo "✅ Development environment ready!" - -# Check system dependencies -check-deps: - @echo "=== System Dependencies Check ===" - @echo "Checking required tools..." - @command -v rustc >/dev/null && echo "✅ Rust compiler found" || echo "❌ Rust compiler not found" - @command -v cargo >/dev/null && echo "✅ Cargo found" || echo "❌ Cargo not found" - @command -v git >/dev/null && echo "✅ Git found" || echo "❌ Git not found" - @command -v docker >/dev/null && echo "✅ Docker found" || echo "❌ Docker not found" - @command -v just >/dev/null && echo "✅ Just found" || echo "❌ Just not found" - @echo "" - @echo "Optional tools:" - @command -v libreoffice >/dev/null && echo "✅ LibreOffice found" || echo "⚠️ LibreOffice not found (optional)" - @command -v pdftoppm >/dev/null && echo "✅ pdftoppm found" || echo "⚠️ pdftoppm not found (optional)" - @command -v convert >/dev/null && echo "✅ ImageMagick convert found" || echo "⚠️ ImageMagick not found (optional)" - -# Cross-compilation commands - -# Build for all supported targets -build-all-targets: - # Install targets if not present - rustup target add x86_64-unknown-linux-gnu - rustup target add x86_64-unknown-linux-musl - rustup target add aarch64-unknown-linux-gnu - rustup target add x86_64-apple-darwin - rustup target add aarch64-apple-darwin - rustup target add x86_64-pc-windows-msvc - # Build for each target - cargo build --release --target x86_64-unknown-linux-gnu --all-features - cargo build --release --target x86_64-unknown-linux-musl --all-features - cargo build --release --target x86_64-apple-darwin --all-features - @echo "✅ Built for all available targets" - -# Build using cross for Linux targets -build-cross-linux: - cargo install cross --git https://github.com/cross-rs/cross - cross build --release --target x86_64-unknown-linux-gnu --all-features - cross build --release --target x86_64-unknown-linux-musl --all-features - cross build --release --target aarch64-unknown-linux-gnu --all-features - cross build --release --target aarch64-unknown-linux-musl --all-features - -# Maintenance commands - -# Update all dependencies to latest versions -update-deps: - cargo update - cargo outdated --depth 1 - -# Check for security vulnerabilities and update -security-update: - cargo audit fix - cargo update - -# Clean everything (including registry cache) -clean-all: - cargo clean - rm -rf ~/.cargo/registry/cache - rm -rf ~/.cargo/git/db - docker system prune -f - -# Backup project (excluding target and build artifacts) -backup: - #!/usr/bin/env bash - BACKUP_NAME="docx-mcp-backup-$(date +%Y%m%d-%H%M%S)" - tar czf "${BACKUP_NAME}.tar.gz" \ - --exclude='target' \ - --exclude='.git' \ - --exclude='*.log' \ - --exclude='*.tmp' \ - . - echo "✅ Backup created: ${BACKUP_NAME}.tar.gz" - -# Development workflows - -# Quick development loop (format, build, test unit, lint) -dev-loop: - just fmt - just build - just test-unit - just clippy - -# Full quality check (everything CI runs) -quality-check: - just fmt-check - just clippy - just test - just docs-check - just audit - just deny - -# Continuous development with file watching -dev-watch: - cargo install cargo-watch - cargo watch -w src -w tests -x "build" -x "test --lib" - -# Performance analysis -perf-analysis: - # Build optimized release - cargo build --release --all-features - # Run criterion benchmarks - cargo bench --all-features - # Generate flamegraph if available - @if command -v flamegraph >/dev/null 2>&1; then \ - echo "Generating flamegraph..."; \ - cargo flamegraph --bin docx-mcp -- --help; \ - fi - -# MCP-specific commands - -# Test MCP server functionality -test-mcp: - @echo "Testing MCP server..." - # Build the server - cargo build --release --all-features - # Run basic functionality test - python3 example/test_client.py || echo "❌ MCP test failed" - -# Generate MCP documentation -mcp-docs: - @echo "Generating MCP server documentation..." - cargo run --bin docx-mcp -- --help > docs/CLI_REFERENCE.md - @echo "✅ CLI reference updated" - -# Example commands - -# Run all examples -run-examples: - @echo "Running all examples..." - @if [ -f example/test_client.py ]; then python3 example/test_client.py; fi - @if [ -f example/automation_example.py ]; then python3 example/automation_example.py; fi - -# Generate test documents -gen-test-docs: - @echo "Generating test documents..." - mkdir -p test-docs - # You could add commands here to generate various test DOCX files - -# Utility commands - -# Show detailed project info -info: - @echo "=== Project Information ===" - @echo "Name: docx-mcp" - @echo "Version: $(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')" - @echo "Rust version: $(rustc --version)" - @echo "Cargo version: $(cargo --version)" - @echo "" - just stats - -# List all available commands with descriptions -help: - @echo "=== Available Commands ===" - @just --list - @echo "" - @echo "=== Release Commands ===" - @echo " release-patch - Create patch release (0.1.0 -> 0.1.1)" - @echo " release-minor - Create minor release (0.1.0 -> 0.2.0)" - @echo " release-major - Create major release (0.1.0 -> 1.0.0)" - @echo " release-version X - Create specific version release" - @echo " release-*-dry - Dry run versions of above commands" - @echo "" - @echo "=== Development Workflows ===" - @echo " dev-loop - Quick development cycle" - @echo " quality-check - Full quality assessment" - @echo " dev-setup - Complete development environment setup" \ No newline at end of file diff --git a/py-docx/Dockerfile b/py-docx/Dockerfile deleted file mode 100644 index 9223c49..0000000 --- a/py-docx/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -# Dockerfile for py-docx-mcp (Python MCP server) - OpenWebUI: MCP (Streamable HTTP) -# Usage: -# docker build -t py-docx-mcp -f py-docx/Dockerfile . -# docker run --rm -p 3000:3000 py-docx-mcp -# -# In OpenWebUI: -# - Type: MCP (Streamable HTTP) -# - URL: http://:3000 -# - Auth: Bearer (if DOCX_MCP_API_KEY is set) -# -# Environment: -# DOCX_MCP_API_KEY - API key (Bearer or X-API-Key). Optional but recommended. -# DOCX_MCP_HTTP_HOST - Bind host (default: 0.0.0.0) -# DOCX_MCP_HTTP_PORT - Bind port (default: 3000) -# DOCX_MCP_TEMPLATES_DIR - Templates directory (default: /templates) -# DOCX_MCP_MAX_SIZE - Max document size in bytes (default: 104857600) -# DOCX_MCP_MAX_DOCS - Max open documents (default: 30) -# DOCX_MCP_SANDBOX - Enable sandbox mode (default: true) -# DOCX_MCP_ALLOW_EXTERNAL_TOOLS - Allow external tools (default: false) -# DOCX_MCP_ALLOW_NETWORK - Allow network access (default: false) - -FROM python:3.12-slim AS base - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=off \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -WORKDIR /app - -# System deps (for python-docx, Pillow, and optional external converters) -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - libjpeg62-turbo-dev \ - libpng-dev \ - libfreetype6-dev \ - libfontconfig1-dev \ - libreoffice \ - poppler-utils \ - && rm -rf /var/lib/apt/lists/* - -# Install Python dependencies -COPY py-docx/pyproject.toml ./ -RUN pip install --upgrade pip && pip install . - -# Copy source -COPY py-docx/src/py_docx ./src/py_docx - -# Create runtime dirs -RUN mkdir -p /templates /out /tmp/py-docx-mcp - -# Environment -ENV DOCX_MCP_HTTP_HOST=0.0.0.0 \ - DOCX_MCP_HTTP_PORT=3000 \ - DOCX_MCP_TEMPLATES_DIR=/templates \ - DOCX_MCP_MAX_SIZE=104857600 \ - DOCX_MCP_MAX_DOCS=30 \ - DOCX_MCP_SANDBOX=true \ - DOCX_MCP_ALLOW_EXTERNAL_TOOLS=true \ - DOCX_MCP_ALLOW_NETWORK=false - -# Expose HTTP port (Streamable HTTP for OpenWebUI) -EXPOSE 3000 - -# Health check (ensure module is importable) -HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ - CMD python -c "import py_docx.server; print('ok')" || exit 1 - -# Default: Streamable HTTP for OpenWebUI MCP -ENTRYPOINT ["python", "-m", "py_docx.server"] diff --git a/py-docx/pyproject.toml b/pyproject.toml similarity index 100% rename from py-docx/pyproject.toml rename to pyproject.toml diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index 2af1165..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,355 +0,0 @@ -#!/bin/bash - -# Release script for docx-mcp -# This script helps with version management and release preparation - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Helper functions -info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -success() { - echo -e "${GREEN}✅ $1${NC}" -} - -warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -error() { - echo -e "${RED}❌ $1${NC}" -} - -# Check if we're in a git repository -check_git_repo() { - if ! git rev-parse --git-dir > /dev/null 2>&1; then - error "Not in a git repository" - exit 1 - fi -} - -# Check if working directory is clean -check_clean_working_dir() { - if ! git diff-index --quiet HEAD --; then - error "Working directory is not clean. Please commit or stash your changes." - exit 1 - fi -} - -# Get current version from Cargo.toml -get_current_version() { - grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/' -} - -# Update version in Cargo.toml -update_version() { - local new_version=$1 - info "Updating version to $new_version" - - # Update Cargo.toml - sed -i.bak "s/^version = \".*\"/version = \"$new_version\"/" Cargo.toml - rm Cargo.toml.bak - - # Update Cargo.lock - cargo update -p docx-mcp - - success "Version updated to $new_version" -} - -# Generate changelog since last tag -generate_changelog() { - local last_tag=$(git tag --sort=-version:refname | head -1) - local new_version=$1 - - info "Generating changelog since $last_tag" - - if [ -n "$last_tag" ]; then - git log --pretty=format:"- %s (%h)" --no-merges ${last_tag}..HEAD > CHANGELOG.tmp - else - git log --pretty=format:"- %s (%h)" --no-merges > CHANGELOG.tmp - fi - - echo "## Release $new_version ($(date +%Y-%m-%d))" - echo "" - cat CHANGELOG.tmp - echo "" - rm CHANGELOG.tmp -} - -# Run pre-release checks -run_checks() { - info "Running pre-release checks..." - - # Format check - info "Checking code formatting..." - cargo fmt --all -- --check - success "Code formatting is correct" - - # Clippy check - info "Running Clippy..." - cargo clippy --all-targets --all-features -- -D warnings - success "Clippy checks passed" - - # Tests - info "Running tests..." - cargo test --all-features - success "All tests passed" - - # Build check - info "Testing release build..." - cargo build --release --all-features - success "Release build successful" - - # Package check - info "Testing package..." - cargo package --dry-run - success "Package validation passed" -} - -# Create and push git tag -create_tag() { - local version=$1 - local tag="v$version" - - info "Creating git tag $tag" - - # Create annotated tag - git tag -a "$tag" -m "Release $tag" - - success "Created tag $tag" - - # Ask if user wants to push - echo -n "Push tag to origin? [y/N]: " - read -r response - if [[ "$response" =~ ^[Yy]$ ]]; then - git push origin "$tag" - success "Tag pushed to origin" - else - warning "Tag not pushed. Remember to push it manually: git push origin $tag" - fi -} - -# Show usage information -usage() { - cat << EOF -Usage: $0 [COMMAND] [OPTIONS] - -Commands: - patch Bump patch version (0.1.0 -> 0.1.1) - minor Bump minor version (0.1.0 -> 0.2.0) - major Bump major version (0.1.0 -> 1.0.0) - version X.Y.Z Set specific version - check Run pre-release checks only - changelog Generate changelog since last tag - tag Create git tag for current version - -Options: - --dry-run Show what would be done without making changes - --no-checks Skip pre-release checks (not recommended) - --no-tag Don't create git tag - --help Show this help message - -Examples: - $0 patch # Bump to next patch version - $0 version 1.0.0 # Set version to 1.0.0 - $0 check # Run all pre-release checks - $0 patch --dry-run # Show what patch release would do -EOF -} - -# Parse version bump type -bump_version() { - local current_version=$1 - local bump_type=$2 - - # Split version into components - IFS='.' read -ra VERSION_PARTS <<< "$current_version" - local major=${VERSION_PARTS[0]} - local minor=${VERSION_PARTS[1]} - local patch=${VERSION_PARTS[2]} - - case $bump_type in - "patch") - patch=$((patch + 1)) - ;; - "minor") - minor=$((minor + 1)) - patch=0 - ;; - "major") - major=$((major + 1)) - minor=0 - patch=0 - ;; - *) - error "Invalid bump type: $bump_type" - exit 1 - ;; - esac - - echo "${major}.${minor}.${patch}" -} - -# Validate version format -validate_version() { - local version=$1 - if ! [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then - error "Invalid version format: $version" - error "Expected format: X.Y.Z or X.Y.Z-suffix" - exit 1 - fi -} - -# Main script logic -main() { - local command=$1 - local dry_run=false - local no_checks=false - local no_tag=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - dry_run=true - shift - ;; - --no-checks) - no_checks=true - shift - ;; - --no-tag) - no_tag=true - shift - ;; - --help) - usage - exit 0 - ;; - *) - if [ -z "$command" ]; then - command=$1 - elif [ -z "$version_arg" ] && [ "$command" = "version" ]; then - version_arg=$1 - fi - shift - ;; - esac - done - - # Check if command provided - if [ -z "$command" ]; then - usage - exit 1 - fi - - # Basic checks - check_git_repo - - if [ "$dry_run" = false ]; then - check_clean_working_dir - fi - - current_version=$(get_current_version) - info "Current version: $current_version" - - case $command in - "patch"|"minor"|"major") - new_version=$(bump_version "$current_version" "$command") - ;; - "version") - if [ -z "$version_arg" ]; then - error "Version argument required for 'version' command" - exit 1 - fi - new_version=$version_arg - validate_version "$new_version" - ;; - "check") - run_checks - success "All pre-release checks passed!" - exit 0 - ;; - "changelog") - generate_changelog "$current_version" - exit 0 - ;; - "tag") - if [ "$dry_run" = true ]; then - info "Would create tag v$current_version" - else - create_tag "$current_version" - fi - exit 0 - ;; - *) - error "Unknown command: $command" - usage - exit 1 - ;; - esac - - info "New version will be: $new_version" - - if [ "$dry_run" = true ]; then - warning "DRY RUN MODE - No changes will be made" - info "Would update version from $current_version to $new_version" - if [ "$no_checks" = false ]; then - info "Would run pre-release checks" - fi - if [ "$no_tag" = false ]; then - info "Would create git tag v$new_version" - fi - exit 0 - fi - - # Confirm with user - echo -n "Proceed with release $new_version? [y/N]: " - read -r response - if [[ ! "$response" =~ ^[Yy]$ ]]; then - warning "Release cancelled" - exit 0 - fi - - # Run pre-release checks - if [ "$no_checks" = false ]; then - run_checks - fi - - # Update version - update_version "$new_version" - - # Commit version bump - git add Cargo.toml Cargo.lock - git commit -m "Release $new_version" - success "Version bump committed" - - # Create tag - if [ "$no_tag" = false ]; then - create_tag "$new_version" - fi - - # Generate changelog for reference - info "Changelog for release:" - generate_changelog "$new_version" - - success "Release $new_version completed!" - info "Next steps:" - info "1. Push commits: git push origin main" - if [ "$no_tag" = false ]; then - info "2. Push tag: git push origin v$new_version (if not done already)" - fi - info "3. GitHub Actions will automatically create the release" -} - -# Run main function with all arguments -main "$@" \ No newline at end of file diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh deleted file mode 100755 index b96c049..0000000 --- a/scripts/run_tests.sh +++ /dev/null @@ -1,312 +0,0 @@ -#!/bin/bash - -# Comprehensive test runner script for docx-mcp -# Usage: ./scripts/run_tests.sh [OPTIONS] - -set -euo pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Default options -RUN_UNIT_TESTS=true -RUN_INTEGRATION_TESTS=true -RUN_PERFORMANCE_TESTS=false -RUN_BENCHMARKS=false -RUN_SECURITY_AUDIT=true -RUN_COVERAGE=false -VERBOSE=false -QUIET=false -CLEAN_FIRST=false - -# Function to print colored output -print_status() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Function to show help -show_help() { - cat << EOF -Usage: $0 [OPTIONS] - -Test runner script for docx-mcp project - -OPTIONS: - -h, --help Show this help message - -u, --unit-only Run only unit tests - -i, --integration-only Run only integration tests - -p, --performance Include performance tests (slow) - -b, --benchmarks Run benchmarks (slow) - -s, --skip-security Skip security audit - -c, --coverage Generate coverage report - -v, --verbose Verbose output - -q, --quiet Quiet output (errors only) - --clean Clean build artifacts first - --all Run all tests including slow ones - -Examples: - $0 # Run standard test suite - $0 -u # Run only unit tests - $0 --all # Run all tests including performance - $0 -c --verbose # Generate coverage with verbose output -EOF -} - -# Parse command line arguments -while [[ $# -gt 0 ]]; do - case $1 in - -h|--help) - show_help - exit 0 - ;; - -u|--unit-only) - RUN_UNIT_TESTS=true - RUN_INTEGRATION_TESTS=false - shift - ;; - -i|--integration-only) - RUN_UNIT_TESTS=false - RUN_INTEGRATION_TESTS=true - shift - ;; - -p|--performance) - RUN_PERFORMANCE_TESTS=true - shift - ;; - -b|--benchmarks) - RUN_BENCHMARKS=true - shift - ;; - -s|--skip-security) - RUN_SECURITY_AUDIT=false - shift - ;; - -c|--coverage) - RUN_COVERAGE=true - shift - ;; - -v|--verbose) - VERBOSE=true - shift - ;; - -q|--quiet) - QUIET=true - shift - ;; - --clean) - CLEAN_FIRST=true - shift - ;; - --all) - RUN_UNIT_TESTS=true - RUN_INTEGRATION_TESTS=true - RUN_PERFORMANCE_TESTS=true - RUN_BENCHMARKS=true - shift - ;; - *) - print_error "Unknown option: $1" - show_help - exit 1 - ;; - esac -done - -# Set up output redirection based on quiet flag -if [ "$QUIET" = true ]; then - CARGO_OUTPUT="--quiet" -else - CARGO_OUTPUT="" -fi - -if [ "$VERBOSE" = true ]; then - CARGO_OUTPUT="$CARGO_OUTPUT --verbose" -fi - -print_status "Starting docx-mcp test suite" - -# Check if we're in the right directory -if [ ! -f "Cargo.toml" ]; then - print_error "Cargo.toml not found. Please run this script from the project root." - exit 1 -fi - -# Check if Rust is installed -if ! command -v cargo &> /dev/null; then - print_error "Cargo not found. Please install Rust first." - exit 1 -fi - -# Clean build artifacts if requested -if [ "$CLEAN_FIRST" = true ]; then - print_status "Cleaning build artifacts..." - cargo clean $CARGO_OUTPUT -fi - -# Check formatting -print_status "Checking code formatting..." -if ! cargo fmt --all -- --check; then - print_error "Code formatting issues found. Run 'cargo fmt' to fix." - exit 1 -fi -print_success "Code formatting OK" - -# Run Clippy lints -print_status "Running Clippy lints..." -if ! cargo clippy --all-targets --all-features $CARGO_OUTPUT -- -D warnings; then - print_error "Clippy lints failed" - exit 1 -fi -print_success "Clippy lints passed" - -# Build the project -print_status "Building project..." -if ! cargo build --all-features $CARGO_OUTPUT; then - print_error "Build failed" - exit 1 -fi -print_success "Build completed" - -# Initialize test results tracking -TESTS_PASSED=0 -TESTS_FAILED=0 -FAILED_TESTS=() - -# Function to run a test and track results -run_test() { - local test_name="$1" - local test_command="$2" - - print_status "Running $test_name..." - - if eval $test_command; then - print_success "$test_name passed" - ((TESTS_PASSED++)) - else - print_error "$test_name failed" - ((TESTS_FAILED++)) - FAILED_TESTS+=("$test_name") - fi -} - -# Run unit tests -if [ "$RUN_UNIT_TESTS" = true ]; then - run_test "unit tests" "cargo test --lib $CARGO_OUTPUT" - run_test "doc tests" "cargo test --doc $CARGO_OUTPUT" -fi - -# Run integration tests -if [ "$RUN_INTEGRATION_TESTS" = true ]; then - run_test "DOCX handler tests" "cargo test --test docx_handler_tests $CARGO_OUTPUT" - run_test "MCP integration tests" "cargo test --test mcp_integration_tests $CARGO_OUTPUT" - run_test "security tests" "cargo test --test security_tests $CARGO_OUTPUT" - run_test "converter tests" "cargo test --test converter_tests $CARGO_OUTPUT" - run_test "end-to-end workflow tests" "cargo test --test e2e_workflow_tests $CARGO_OUTPUT" -fi - -# Run performance tests (if requested) -if [ "$RUN_PERFORMANCE_TESTS" = true ]; then - print_warning "Running performance tests (this may take a while)..." - run_test "performance tests" "cargo test --test performance_tests $CARGO_OUTPUT --release" -fi - -# Run benchmarks (if requested) -if [ "$RUN_BENCHMARKS" = true ]; then - print_warning "Running benchmarks (this may take a while)..." - run_test "benchmarks" "cargo bench --all-features $CARGO_OUTPUT" -fi - -# Run security audit -if [ "$RUN_SECURITY_AUDIT" = true ]; then - print_status "Running security audit..." - - # Install cargo-audit if not present - if ! command -v cargo-audit &> /dev/null; then - print_status "Installing cargo-audit..." - cargo install cargo-audit - fi - - run_test "security audit" "cargo audit" - - # Check for denied dependencies if cargo-deny is available - if command -v cargo-deny &> /dev/null; then - run_test "dependency check" "cargo deny check" - else - print_warning "cargo-deny not found, skipping dependency checks" - fi -fi - -# Generate coverage report (if requested) -if [ "$RUN_COVERAGE" = true ]; then - print_status "Generating coverage report..." - - # Check if cargo-llvm-cov is installed - if ! command -v cargo-llvm-cov &> /dev/null; then - print_status "Installing cargo-llvm-cov..." - cargo install cargo-llvm-cov - fi - - if cargo llvm-cov --all-features --workspace --html; then - print_success "Coverage report generated in target/llvm-cov/html/" - - # Also generate lcov format for CI - if cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info; then - print_success "LCOV format generated as lcov.info" - fi - else - print_error "Coverage generation failed" - ((TESTS_FAILED++)) - FAILED_TESTS+=("coverage generation") - fi -fi - -# Test different feature configurations -print_status "Testing different feature configurations..." - -run_test "minimal features" "cargo test --no-default-features $CARGO_OUTPUT" -run_test "all features" "cargo test --all-features $CARGO_OUTPUT" - -# Check that package builds for release -print_status "Verifying release build..." -run_test "release build" "cargo build --release --all-features $CARGO_OUTPUT" - -# Verify package can be published (dry run) -print_status "Verifying package..." -run_test "package verification" "cargo package --dry-run $CARGO_OUTPUT" - -# Print final results -echo "" -print_status "============ Test Results Summary ============" - -if [ $TESTS_FAILED -eq 0 ]; then - print_success "All tests passed! ($TESTS_PASSED/$((TESTS_PASSED + TESTS_FAILED)))" - echo "" - print_status "Ready for deployment! 🚀" - exit 0 -else - print_error "Some tests failed! ($TESTS_PASSED passed, $TESTS_FAILED failed)" - echo "" - print_error "Failed tests:" - for test in "${FAILED_TESTS[@]}"; do - echo -e " ${RED}✗${NC} $test" - done - echo "" - print_error "Please fix the failing tests before proceeding." - exit 1 -fi \ No newline at end of file diff --git a/src/advanced_docx.rs b/src/advanced_docx.rs deleted file mode 100644 index 31995a9..0000000 --- a/src/advanced_docx.rs +++ /dev/null @@ -1,716 +0,0 @@ -use anyhow::{Context, Result}; -use docx_rs::*; -use std::collections::HashMap; -use std::fs::File; -use std::io::Read; -use std::path::Path; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use base64; - -/// Advanced DOCX manipulation features -pub struct AdvancedDocxHandler; - -impl AdvancedDocxHandler { - pub fn new() -> Self { - Self - } - - /// Create a document with professional template - pub fn create_from_template(&self, template_type: DocumentTemplate) -> Result { - 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 { - // Basic TOC insertion (heading text paragraph + placeholder) - let mut docx = docx.add_paragraph( - Paragraph::new() - .add_run(Run::new().add_text("Table of Contents").bold().size(28)) - .style("TOCHeading") - ); - - // 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 { - // Try to attach a Drawing to the Run via RunChild using the public add_pic shortcut - let pic = Pic::new_with_dimensions(_image_data.to_vec(), width_px, height_px); - let paragraph = Paragraph::new().add_run({ - let run = Run::new(); - run.add_image(pic) - }); - Ok(docx.add_paragraph(paragraph)) - } - - /// Add a chart to the document - pub fn add_chart(&self, docx: Docx, chart_type: ChartType, data: ChartData) -> Result { - // 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 { - 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 { - // Bookmark IDs in 0.4 are usize; fallback to plain paragraph with text - let paragraph = Paragraph::new().add_run(Run::new().add_text(text)); - - Ok(docx.add_paragraph(paragraph)) - } - - /// Add a cross-reference - pub fn add_cross_reference(&self, docx: Docx, bookmark_name: &str, display_text: &str) -> Result { - // Cross-references in DOCX use field codes - // Complex field support is limited in current docx-rs; fallback to plain hyperlink - // Fallback: hyperlink not wired; emit text with target in brackets - let paragraph = Paragraph::new().add_run(Run::new().add_text(format!("{} ({})", display_text, bookmark_name))); - - Ok(docx.add_paragraph(paragraph)) - } - - /// Add document properties and metadata - pub fn set_document_properties(&self, docx: Docx, _properties: DocumentProperties) -> Result { - // Metadata setters not exposed; return unchanged - Ok(docx) - } - - /// Add a custom styled section - pub fn add_section(&self, docx: Docx, section_config: SectionConfig) -> Result { - // Basic section properties (defaults). Page size/columns APIs differ; using defaults. - Ok(docx) - } - - /// Add a watermark - pub fn add_watermark(&self, docx: Docx, text: &str, style: WatermarkStyle) -> Result { - 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 { - let footnote_id = Uuid::new_v4().to_string(); - - // docx-rs footnote APIs are in flux; append note text inline as fallback - let paragraph = Paragraph::new() - .add_run(Run::new().add_text(reference_text)) - .add_run(Run::new().add_text(format!(" [{}]", footnote_text))); - Ok(docx.add_paragraph(paragraph)) - } - - /// Add endnote - pub fn add_endnote(&self, docx: Docx, reference_text: &str, endnote_text: &str) -> Result { - let endnote_id = Uuid::new_v4().to_string(); - - // Fallback inline rendering for endnotes - let paragraph = Paragraph::new() - .add_run(Run::new().add_text(reference_text)) - .add_run(Run::new().add_text(format!(" [{}]", endnote_text))); - Ok(docx.add_paragraph(paragraph)) - } - - /// Add custom styles - pub fn add_custom_style(&self, docx: Docx, _style: CustomStyle) -> Result { - // Style builder APIs differ; skip custom styles for now - Ok(docx) - } - - /// Mail merge functionality - pub fn prepare_mail_merge_template(&self, docx: Docx, fields: Vec) -> Result { - let mut docx = docx; - - for field in fields { - let paragraph = Paragraph::new() - .add_run(Run::new().add_text(format!("«{}»", field))); - - docx = docx.add_paragraph(paragraph); - } - - Ok(docx) - } - - /// Add comments (annotations) - pub fn add_comment(&self, docx: Docx, text: &str, comment: &str, author: &str) -> Result { - let comment_id = Uuid::new_v4().to_string(); - let date = Utc::now(); - - // Fallback: inline annotation style rendering (no true comment element) - let paragraph = Paragraph::new() - .add_run(Run::new().add_text(text)) - .add_run(Run::new().add_text(format!(" [Comment by {}: {}]", author, comment))); - Ok(docx.add_paragraph(paragraph)) - } - - // Template helper methods - - fn apply_business_letter_template(&self, mut docx: Docx) -> Result { - // 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 { - // 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 { - // 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 { - // 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![]) - .add_row(TableRow::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 { - // 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 { - // 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]")) - ); - - // Divider line - let mut divider = Paragraph::new(); - for _ in 0..70 { divider = divider.add_run(Run::new().add_text("_")); } - docx = docx.add_paragraph(divider); - - Ok(docx) - } - - fn apply_newsletter_template(&self, mut docx: Docx) -> Result { - // 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 requires section APIs; skip for now - - 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, - pub description: String, - pub company: Option, - pub manager: Option, -} - -#[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, - pub series: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChartSeries { - pub name: String, - pub values: Vec, -} - -#[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, - pub font: Option, - pub size: Option, - pub bold: bool, - pub italic: bool, - pub color: Option, - pub spacing: Option, - pub indent: Option, -} - -#[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, -} \ No newline at end of file diff --git a/src/bin/embed_image.rs b/src/bin/embed_image.rs deleted file mode 100644 index 9883973..0000000 --- a/src/bin/embed_image.rs +++ /dev/null @@ -1,45 +0,0 @@ -use std::fs::{self, File}; -use std::path::PathBuf; - -use anyhow::Result; -use docx_rs::{Docx, Paragraph, Run, Pic, BreakType}; - -fn main() -> Result<()> { - // Generate a simple 100x100 PNG in-memory (red square) - let width = 100u32; - let height = 100u32; - let mut img = ::image::RgbaImage::new(width, height); - for y in 0..height { - for x in 0..width { - img.put_pixel(x, y, ::image::Rgba([255, 0, 0, 255])); - } - } - let mut png_bytes: Vec = Vec::new(); - let dyn_img = ::image::DynamicImage::ImageRgba8(img); - dyn_img.write_to(&mut std::io::Cursor::new(&mut png_bytes), ::image::ImageFormat::Png)?; - - // Build a DOCX with an image and a caption - let mut docx = Docx::new(); - - let para = Paragraph::new() - .add_run(Run::new().add_text("Embedded image demo").bold().size(28)) - .add_run(Run::new().add_break(BreakType::TextWrapping)); - docx = docx.add_paragraph(para); - - let image_para = Paragraph::new().add_run({ - let run = Run::new(); - run.add_image(Pic::new_with_dimensions(png_bytes, width, height)) - }); - docx = docx.add_paragraph(image_para); - - // Ensure output directory exists - let out_dir = PathBuf::from("example/output"); - fs::create_dir_all(&out_dir)?; - let out_path = out_dir.join("embed_image.docx"); - - let file = File::create(&out_path)?; - docx.build().pack(file)?; - - println!("Wrote {}", out_path.display()); - Ok(()) -} diff --git a/src/converter.rs b/src/converter.rs deleted file mode 100644 index 9255146..0000000 --- a/src/converter.rs +++ /dev/null @@ -1,468 +0,0 @@ -use anyhow::{Context, Result}; -use ::image::{ImageFormat}; -use printpdf::*; -use dotext::MsDoc; -use ::lopdf::{dictionary, Object, ObjectId, Document as LoDocument}; -use std::fs::{self, File}; -use std::io::{BufWriter, Read}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tempfile::NamedTempFile; -use tracing::{debug, info}; - -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: cfg!(feature = "hi-fidelity"), // Prefer external/hi-fi if feature enabled - } - } - - 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(()) - } - - /// Convert with explicit preference overriding internal default - pub fn docx_to_pdf_with_preference(&self, docx_path: &Path, pdf_path: &Path, prefer_external: bool) -> Result<()> { - if prefer_external { - if self.try_libreoffice_conversion(docx_path, pdf_path).is_ok() { - info!("Successfully converted DOCX to PDF using LibreOffice (explicit preference)"); - return Ok(()); - } - if self.try_unoconv_conversion(docx_path, pdf_path).is_ok() { - info!("Successfully converted DOCX to PDF using unoconv (explicit preference)"); - return Ok(()); - } - } - // Fallback to pure implementation - self.pure_converter.docx_to_pdf_pure(docx_path, pdf_path)?; - info!("Successfully converted DOCX to PDF using pure Rust implementation (explicit preference)"); - Ok(()) - } - - fn try_libreoffice_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> { - let output = Command::new("libreoffice") - .args(&[ - "--headless", - "--invisible", - "--nodefault", - "--nolockcheck", - "--nologo", - "--norestore", - "--convert-to", - "pdf", - "--outdir", - pdf_path.parent().unwrap().to_str().unwrap(), - docx_path.to_str().unwrap(), - ]) - .output(); - - match output { - Ok(output) if output.status.success() => { - // LibreOffice creates the PDF with the same base name - let temp_pdf = pdf_path.parent().unwrap() - .join(docx_path.file_stem().unwrap()) - .with_extension("pdf"); - - if temp_pdf != pdf_path { - fs::rename(&temp_pdf, pdf_path)?; - } - Ok(()) - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("LibreOffice conversion failed: {}", stderr) - } - Err(e) => { - debug!("LibreOffice not available: {}", e); - anyhow::bail!("LibreOffice not available") - } - } - } - - fn try_unoconv_conversion(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> { - let output = Command::new("unoconv") - .args(&[ - "-f", "pdf", - "-o", pdf_path.to_str().unwrap(), - docx_path.to_str().unwrap(), - ]) - .output(); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("unoconv conversion failed: {}", stderr) - } - Err(e) => { - debug!("unoconv not available: {}", e); - anyhow::bail!("unoconv not available") - } - } - } - - fn basic_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> { - // Extract text from DOCX (fallback using dotext) - let mut reader = dotext::Docx::open(docx_path) - .with_context(|| format!("Failed to open DOCX {:?}", docx_path))?; - let mut data = String::new(); - use std::io::Read as _; - reader.read_to_string(&mut data)?; - let text = data; - - // Create a basic PDF with the extracted text - let (doc, page1, layer1) = PdfDocument::new("Document", Mm(210.0), Mm(297.0), "Layer 1"); - let _current_layer = doc.get_page(page1).get_layer(layer1); - - // Load a basic font - let font = doc.add_builtin_font(BuiltinFont::Helvetica)?; - - // Split text into lines and add to PDF - let lines: Vec<&str> = text.lines().collect(); - let mut y_position = Mm(280.0); - let line_height = Mm(5.0); - - let mut current_layer = doc.get_page(page1).get_layer(layer1); - for line in lines { - if y_position < Mm(20.0) { - let (page, layer) = doc.add_page(Mm(210.0), Mm(297.0), "Page layer"); - 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> { - // 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> { - 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> { - 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> { - 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> { - // 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 docx_to_images_with_preference( - &self, - docx_path: &Path, - output_dir: &Path, - format: ImageFormat, - dpi: u32, - prefer_external: bool, - ) -> Result> { - let temp_pdf = NamedTempFile::new()?.into_temp_path(); - self.docx_to_pdf_with_preference(docx_path, &temp_pdf, prefer_external)?; - let images = self.pdf_to_images(&temp_pdf, output_dir, format, dpi)?; - Ok(images) - } - - pub fn create_thumbnail( - &self, - image_path: &Path, - output_path: &Path, - width: u32, - height: u32, - ) -> Result<()> { - let img = ::image::open(image_path) - .with_context(|| format!("Failed to open image {:?}", image_path))?; - - let thumbnail = img.thumbnail(width, height); - thumbnail.save(output_path) - .with_context(|| format!("Failed to save thumbnail to {:?}", output_path))?; - - info!("Created thumbnail {}x{} at {:?}", width, height, output_path); - Ok(()) - } - - pub fn merge_pdfs(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> { - // Try using pdftk if available - if self.try_pdftk_merge(pdf_paths, output_path).is_ok() { - info!("Successfully merged PDFs using pdftk"); - return Ok(()); - } - - // Fallback to lopdf for merging - self.merge_pdfs_with_lopdf(pdf_paths, output_path)?; - info!("Successfully merged PDFs using lopdf"); - Ok(()) - } - - fn try_pdftk_merge(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> { - let mut args = Vec::new(); - for path in pdf_paths { - args.push(path.to_str().unwrap()); - } - args.push("cat"); - args.push("output"); - args.push(output_path.to_str().unwrap()); - - let output = Command::new("pdftk") - .args(&args) - .output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("pdftk merge failed: {}", stderr); - } - - Ok(()) - } - - fn merge_pdfs_with_lopdf(&self, pdf_paths: &[PathBuf], output_path: &Path) -> Result<()> { - let mut merged = LoDocument::new(); - merged.version = "1.5".to_string(); - - for pdf_path in pdf_paths { - let mut doc = LoDocument::load(pdf_path)?; - - // Merge pages - for page_id in doc.get_pages().values() { - merged.add_object(doc.get_object(*page_id)?.clone()); - } - } - - merged.save(output_path)?; - Ok(()) - } - - pub fn split_pdf(&self, pdf_path: &Path, output_dir: &Path) -> Result> { - fs::create_dir_all(output_dir)?; - - let doc = LoDocument::load(pdf_path)?; - let pages = doc.get_pages(); - let mut output_paths = Vec::new(); - - for (i, (_, page_id)) in pages.iter().enumerate() { - let mut single_page = LoDocument::new(); - single_page.version = doc.version.clone(); - - // Clone the page to the new document - single_page.add_object(doc.get_object(*page_id)?.clone()); - - let output_path = output_dir.join(format!("page_{:03}.pdf", i + 1)); - single_page.save(&output_path)?; - output_paths.push(output_path); - } - - info!("Split PDF into {} pages", output_paths.len()); - Ok(output_paths) - } -} \ No newline at end of file diff --git a/src/docx_handler.rs b/src/docx_handler.rs deleted file mode 100644 index b5ccde5..0000000 --- a/src/docx_handler.rs +++ /dev/null @@ -1,1813 +0,0 @@ -use anyhow::{Context, Result}; -use docx_rs::*; -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; -use uuid::Uuid; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use tracing::{info, warn}; -use zip::{ZipArchive, ZipWriter}; -use zip::write::FileOptions; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocxMetadata { - pub id: String, - pub path: PathBuf, - pub created_at: DateTime, - pub modified_at: DateTime, - pub size_bytes: u64, - pub page_count: Option, - pub word_count: Option, - pub author: Option, - pub title: Option, - pub subject: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DocxStyle { - pub font_family: Option, - pub font_size: Option, - pub bold: Option, - pub italic: Option, - pub underline: Option, - pub color: Option, - pub alignment: Option, - pub line_spacing: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TableData { - pub rows: Vec>, - pub headers: Option>, - pub border_style: Option, - pub col_widths: Option>, // approximate column widths (px) - pub merges: Option>, // best-effort merge specs - pub cell_shading: Option, // hex RGB like "EEEEEE" -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TableMerge { - pub row: usize, - pub col: usize, - pub row_span: usize, - pub col_span: usize, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImageData { - pub data: Vec, - pub width: Option, - pub height: Option, - pub alt_text: Option, -} - -pub struct DocxHandler { - temp_dir: PathBuf, - pub documents: std::collections::HashMap, - // In-memory operations for documents created via this handler - in_memory_ops: std::collections::HashMap>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind")] -pub enum RangeId { - Paragraph { index: usize }, - Heading { index: usize }, - TableCell { table_index: usize, row: usize, col: usize }, -} - -impl DocxHandler { - pub fn new() -> Result { - let base = std::env::var_os("DOCX_MCP_TEMP").map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir()); - let temp_dir = base.join("docx-mcp"); - fs::create_dir_all(&temp_dir)?; - - Ok(Self { - temp_dir, - documents: std::collections::HashMap::new(), - in_memory_ops: std::collections::HashMap::new(), - }) - } - - /// Create a handler that stores temporary documents under the provided base directory - pub fn new_with_base_dir>(base_dir: P) -> Result { - let temp_dir = base_dir.as_ref().join("docx-mcp"); - fs::create_dir_all(&temp_dir)?; - Ok(Self { - temp_dir, - documents: std::collections::HashMap::new(), - in_memory_ops: std::collections::HashMap::new(), - }) - } - - #[cfg(test)] - pub fn new_with_temp_dir(temp_dir: &Path) -> Result { - let temp_dir = temp_dir.to_path_buf(); - fs::create_dir_all(&temp_dir)?; - - Ok(Self { - temp_dir, - documents: std::collections::HashMap::new(), - in_memory_ops: std::collections::HashMap::new(), - }) - } - - pub fn create_document(&mut self) -> Result { - let doc_id = Uuid::new_v4().to_string(); - let doc_path = self.temp_dir.join(format!("{}.docx", doc_id)); - - // Initialize empty document on disk - if let Some(parent) = doc_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create parent directory for {:?}", doc_path))?; - } - let docx = Docx::new(); - let file = File::create(&doc_path) - .with_context(|| format!("Failed to create DOCX file at {:?}", doc_path))?; - docx.build().pack(file) - .with_context(|| format!("Failed to write DOCX package at {:?}", doc_path))?; - - 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); - self.in_memory_ops.insert(doc_id.clone(), Vec::new()); - info!("Created new document with ID: {}", doc_id); - - Ok(doc_id) - } - - pub fn open_document(&mut self, path: &Path) -> Result { - let doc_id = Uuid::new_v4().to_string(); - let doc_path = self.temp_dir.join(format!("{}.docx", doc_id)); - - if let Some(parent) = doc_path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("Failed to create parent directory for {:?}", doc_path))?; - } - 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) -> Result<()> { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Paragraph { text: text.to_string(), style }); - self.write_docx(doc_id)?; - 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 heading_style = match level { - 1 => "Heading1", - 2 => "Heading2", - 3 => "Heading3", - 4 => "Heading4", - 5 => "Heading5", - 6 => "Heading6", - _ => "Heading1", - }; - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Heading { text: text.to_string(), style: heading_style.to_string() }); - self.write_docx(doc_id)?; - 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))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Table { data: table_data }); - self.write_docx(doc_id)?; - info!("Added table to document {}", doc_id); - Ok(()) - } - - pub fn add_list(&mut self, doc_id: &str, items: Vec, ordered: bool) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::List { items, ordered }); - self.write_docx(doc_id)?; - info!("Added {} list to document {}", if ordered { "ordered" } else { "unordered" }, doc_id); - Ok(()) - } - - /// Add a single list item with an explicit indent level (0-based) - pub fn add_list_item(&mut self, doc_id: &str, text: &str, level: usize, ordered: bool) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::ListItem { text: text.to_string(), level, ordered }); - self.write_docx(doc_id)?; - info!("Added list item (level {}) to document {}", level, doc_id); - Ok(()) - } - - /// Add an image to the document - pub fn add_image(&mut self, doc_id: &str, image: ImageData) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - let width = image.width.unwrap_or(100); - let height = image.height.unwrap_or(100); - ops.push(DocxOp::Image { data: image.data, width, height, alt_text: image.alt_text }); - self.write_docx(doc_id)?; - info!("Added image to document {}", doc_id); - Ok(()) - } - - /// Add a hyperlink to the document - pub fn add_hyperlink(&mut self, doc_id: &str, text: &str, url: &str) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Hyperlink { text: text.to_string(), url: url.to_string() }); - self.write_docx(doc_id)?; - info!("Added hyperlink to document {}", doc_id); - Ok(()) - } - - /// Insert a section break with optional page setup (best-effort) - pub fn add_section_break( - &mut self, - doc_id: &str, - page_size: Option<&str>, - orientation: Option<&str>, - margins: Option, - ) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::SectionBreak { - page_size: page_size.map(|s| s.to_string()), - orientation: orientation.map(|s| s.to_string()), - margins, - }); - self.write_docx(doc_id)?; - info!("Added section break to document {}", doc_id); - Ok(()) - } - - /// Insert a Table of Contents placeholder (post-processed into a TOC field when enabled) - pub fn insert_toc(&mut self, doc_id: &str, from_level: usize, to_level: usize, right_align_dots: bool) -> Result<()> { - let _metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Toc { from_level, to_level, right_align_dots }); - self.write_docx(doc_id)?; - Ok(()) - } - - /// Insert a bookmark immediately after the first heading matching text (best-effort) - pub fn insert_bookmark_after_heading(&mut self, doc_id: &str, heading_text: &str, name: &str) -> Result { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - if let Some(pos) = ops.iter().position(|op| matches!(op, DocxOp::Heading { text: t, .. } if t == heading_text)) { - ops.insert(pos + 1, DocxOp::BookmarkAfterHeading { heading_text: heading_text.to_string(), name: name.to_string() }); - self.write_docx(doc_id)?; - return Ok(true); - } - Ok(false) - } - - 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))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::PageBreak); - self.write_docx(doc_id)?; - 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))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Header(text.to_string())); - self.write_docx(doc_id)?; - 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))?; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - ops.push(DocxOp::Footer(text.to_string())); - self.write_docx(doc_id)?; - info!("Set footer for document {}", doc_id); - Ok(()) - } - - /// Convenience: set simple page numbering text in header or footer - pub fn set_page_numbering(&mut self, doc_id: &str, location: &str, template: Option<&str>) -> Result<()> { - let text = template.unwrap_or("Page {PAGE} of {PAGES}"); - match location { - "header" => self.set_header(doc_id, text), - "footer" => self.set_footer(doc_id, text), - _ => anyhow::bail!("invalid location: {}", location), - } - } - - /// Attempt to replace placeholder page numbering text in header with Word field codes (PAGE/NUMPAGES) - /// This is a best-effort, post-processing step that edits the zipped DOCX XML in-place by rebuilding the archive. - pub fn embed_page_number_fields(&self, doc_id: &str) -> Result<()> { - let metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - if !metadata.path.exists() { - anyhow::bail!("Document file missing: {:?}", metadata.path); - } - - let src_file = std::fs::File::open(&metadata.path)?; - let mut archive = ZipArchive::new(src_file)?; - - // Prepare buffer to write new archive - let temp_path = metadata.path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - - let mut did_replace = false; - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - - if (name.starts_with("word/header") || name.starts_with("word/footer")) && name.ends_with(".xml") { - let mut xml = String::new(); - use std::io::Read as _; - file.read_to_string(&mut xml)?; - - if xml.contains("Page {PAGE} of {PAGES}") { - let field_runs = concat!( - "Page ", - "", - " PAGE ", - "", - " of ", - "", - " NUMPAGES ", - "" - ); - xml = xml.replace("Page {PAGE} of {PAGES}", field_runs); - did_replace = true; - } - - writer.start_file(name, options)?; - use std::io::Write as _; - writer.write_all(xml.as_bytes())?; - } else { - // Copy other file entries verbatim - writer.start_file(name, options)?; - use std::io::Read as _; - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - use std::io::Write as _; - writer.write_all(&buf)?; - } - } - - writer.finish()?; - - // Replace original archive only if we changed something - if did_replace { - std::fs::rename(&temp_path, &metadata.path)?; - info!("Embedded PAGE/NUMPAGES fields into header for {}", doc_id); - } else { - // Cleanup temp - let _ = std::fs::remove_file(&temp_path); - info!("No placeholder found to replace for page numbering in {}", doc_id); - } - - Ok(()) - } - - pub fn find_and_replace(&mut self, doc_id: &str, _find_text: &str, _replace_text: &str) -> Result { - 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) - } - - /// Advanced find and replace over in-memory operations (LLM-friendly), preserving runs - /// Supports regex, case sensitivity, and whole word boundaries - pub fn find_and_replace_advanced( - &mut self, - doc_id: &str, - pattern: &str, - replacement: &str, - case_sensitive: bool, - whole_word: bool, - use_regex: bool, - ) -> Result { - use regex::RegexBuilder; - - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - - // Build regex - let pattern = if use_regex { pattern.to_string() } else { regex::escape(pattern) }; - let pattern = if whole_word { format!("\\b{}\\b", pattern) } else { pattern }; - let re = RegexBuilder::new(&pattern) - .case_insensitive(!case_sensitive) - .build() - .with_context(|| "Invalid regex pattern")?; - - let mut total_replacements = 0usize; - - let mut replace_text = |text: &str| -> (String, usize) { - let mut count = 0usize; - let result = re.replace_all(text, |_: ®ex::Captures| { - count += 1; - replacement.to_string() - }); - (result.into_owned(), count) - }; - - for op in ops.iter_mut() { - match op { - DocxOp::Paragraph { text, .. } => { - let (new_text, cnt) = replace_text(text); - if cnt > 0 { *text = new_text; total_replacements += cnt; } - } - DocxOp::Heading { text, .. } => { - let (new_text, cnt) = replace_text(text); - if cnt > 0 { *text = new_text; total_replacements += cnt; } - } - DocxOp::List { items, .. } => { - for item in items.iter_mut() { - let (new_text, cnt) = replace_text(item); - if cnt > 0 { *item = new_text; total_replacements += cnt; } - } - } - DocxOp::ListItem { text, .. } => { - let (new_text, cnt) = replace_text(text); - if cnt > 0 { *text = new_text; total_replacements += cnt; } - } - DocxOp::Table { data } => { - for row in data.rows.iter_mut() { - for cell in row.iter_mut() { - let (new_text, cnt) = replace_text(cell); - if cnt > 0 { *cell = new_text; total_replacements += cnt; } - } - } - } - DocxOp::Header(text) | DocxOp::Footer(text) => { - let (new_text, cnt) = replace_text(text); - if cnt > 0 { *text = new_text; total_replacements += cnt; } - } - DocxOp::Image { .. } | DocxOp::Hyperlink { .. } => {} - DocxOp::PageBreak => {} - DocxOp::SectionBreak { .. } => {} - DocxOp::Toc { .. } => {} - DocxOp::BookmarkAfterHeading { .. } => {} - } - } - - // Persist changes - self.write_docx(doc_id)?; - Ok(total_replacements) - } - - /// Analyze document structure using in-memory ops (if available) - pub fn analyze_structure(&self, doc_id: &str) -> Result { - let ops = match self.in_memory_ops.get(doc_id) { - Some(ops) => ops, - None => { - // Fallback to text-based outline if ops not available - let text = self.extract_text(doc_id).unwrap_or_default(); - let mut outline = Vec::new(); - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { continue; } - if trimmed.len() < 100 && trimmed.chars().any(|c| c.is_uppercase()) { - let level = if trimmed.chars().all(|c| c.is_uppercase() || c.is_whitespace()) { 1 } else { 2 }; - outline.push(serde_json::json!({"type":"heading","text":trimmed,"level":level})); - } - } - return Ok(serde_json::json!({ - "has_ops": false, - "outline": outline, - "lists": [], - "tables": [], - "images": [], - "links": [], - "styles": {} - })); - } - }; - - let mut outline = Vec::new(); - let mut lists = Vec::new(); - let mut tables = Vec::new(); - let mut images = Vec::new(); - let mut links = Vec::new(); - let mut styles_used: std::collections::HashMap = std::collections::HashMap::new(); - - for op in ops.iter() { - match op { - DocxOp::Heading { text, style } => { - let level = style.chars().last().and_then(|c| c.to_digit(10)).map(|d| d as usize).unwrap_or(1); - outline.push(serde_json::json!({"text": text, "level": level})); - } - DocxOp::List { items, .. } => { - lists.push(serde_json::json!({"level": 0, "items": items})); - } - DocxOp::ListItem { text, level, .. } => { - lists.push(serde_json::json!({"level": level, "items": [text]})); - } - DocxOp::Table { data } => { - let rows = data.rows.len(); - let cols = data.rows.first().map(|r| r.len()).unwrap_or(0); - tables.push(serde_json::json!({"rows": rows, "cols": cols})); - } - DocxOp::Image { width, height, .. } => { - images.push(serde_json::json!({"width": width, "height": height})); - } - DocxOp::Hyperlink { text, url } => { - links.push(serde_json::json!({"text": text, "url": url})); - } - DocxOp::Paragraph { style, .. } => { - if let Some(s) = style { - if s.bold == Some(true) { *styles_used.entry("bold".into()).or_default() += 1; } - if s.italic == Some(true) { *styles_used.entry("italic".into()).or_default() += 1; } - if s.underline == Some(true) { *styles_used.entry("underline".into()).or_default() += 1; } - if s.font_family.is_some() { *styles_used.entry("font_family".into()).or_default() += 1; } - if s.font_size.is_some() { *styles_used.entry("font_size".into()).or_default() += 1; } - if s.color.is_some() { *styles_used.entry("color".into()).or_default() += 1; } - if s.alignment.is_some() { *styles_used.entry("alignment".into()).or_default() += 1; } - } - } - DocxOp::Header(_) | DocxOp::Footer(_) | DocxOp::PageBreak | DocxOp::SectionBreak { .. } => {} - DocxOp::Toc { .. } => {} - DocxOp::BookmarkAfterHeading { .. } => {} - } - } - - Ok(serde_json::json!({ - "has_ops": true, - "outline": outline, - "lists": lists, - "tables": tables, - "images": images, - "links": links, - "styles": styles_used, - })) - } - - /// Outline with stable indices for headings (range_ids) - pub fn get_outline(&self, doc_id: &str) -> Result { - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut outline = Vec::new(); - let mut heading_idx = 0usize; - for op in ops.iter() { - if let DocxOp::Heading { text, style } = op { - let level = style.chars().last().and_then(|c| c.to_digit(10)).map(|d| d as usize).unwrap_or(1); - outline.push(serde_json::json!({ - "text": text, - "level": level, - "range_id": RangeId::Heading { index: heading_idx } - })); - heading_idx += 1; - } - } - Ok(serde_json::json!({"outline": outline})) - } - - /// Simple selector to ranges. Supported selectors: - /// - heading:'Text' - /// - paragraph[INDEX] - /// - table[T].cell[R,C] - pub fn get_ranges(&self, doc_id: &str, selector: &str) -> Result> { - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut results = Vec::new(); - if let Some(rest) = selector.strip_prefix("heading:") { - let needle = rest.trim().trim_matches('\'').trim_matches('"'); - let mut idx = 0usize; - for op in ops.iter() { - if let DocxOp::Heading { text, .. } = op { - if text == needle { results.push(RangeId::Heading { index: idx }); } - idx += 1; - } - } - return Ok(results); - } - if let Some(start) = selector.strip_prefix("paragraph[") { - if let Some(endpos) = start.find(']') { - if let Ok(pi) = start[..endpos].parse::() { - results.push(RangeId::Paragraph { index: pi }); - return Ok(results); - } - } - } - if let Some(start) = selector.strip_prefix("table[") { - if let Some(endt) = start.find(']') { - let t_str = &start[..endt]; - if let Some(cell_part) = start[endt+1..].strip_prefix(".cell[") { - if let Some(endc) = cell_part.find(']') { - let coords = &cell_part[..endc]; - let mut it = coords.split(','); - if let (Ok(ti), Some(rs), Some(cs)) = ( - t_str.parse::(), - it.next(), it.next() - ) { - if let (Ok(r), Ok(c)) = (rs.trim().parse::(), cs.trim().parse::()) { - results.push(RangeId::TableCell { table_index: ti, row: r, col: c }); - return Ok(results); - } - } - } - } - } - } - Ok(results) - } - - /// Replace text in a given range id (paragraph or heading). For TableCell use set_table_cell_text - pub fn replace_range_text(&mut self, doc_id: &str, range: &RangeId, new_text: &str) -> Result<()> { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - match range { - RangeId::Paragraph { index } => { - let mut para_idx = 0usize; - for op in ops.iter_mut() { - if let DocxOp::Paragraph { text, .. } = op { - if ¶_idx == index { *text = new_text.to_string(); break; } - para_idx += 1; - } - } - } - RangeId::Heading { index } => { - let mut h_idx = 0usize; - for op in ops.iter_mut() { - if let DocxOp::Heading { text, .. } = op { - if &h_idx == index { *text = new_text.to_string(); break; } - h_idx += 1; - } - } - } - RangeId::TableCell { .. } => anyhow::bail!("Use set_table_cell_text for table cells"), - } - self.write_docx(doc_id)?; - Ok(()) - } - - /// Set table cell text by table index and coordinates - pub fn set_table_cell_text(&mut self, doc_id: &str, table_index: usize, row: usize, col: usize, text: &str) -> Result<()> { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut ti = 0usize; - for op in ops.iter_mut() { - if let DocxOp::Table { data } = op { - if ti == table_index { - if row < data.rows.len() && col < data.rows[row].len() { - data.rows[row][col] = text.to_string(); - self.write_docx(doc_id)?; - return Ok(()); - } else { - anyhow::bail!("Cell out of bounds"); - } - } - ti += 1; - } - } - anyhow::bail!("Table not found") - } - - pub fn extract_text(&self, doc_id: &str) -> Result { - 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(&self.documents.get(doc_id).unwrap().path) - .with_context(|| format!("Failed to extract text from document {}", doc_id))?; - - Ok(text) - } - - pub fn get_metadata(&self, doc_id: &str) -> Result { - self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id)) - .map(|m| m.clone()) - } - - /// Update paragraph formatting for paragraphs matching the selector (currently supports substring match) - pub fn apply_paragraph_format( - &mut self, - doc_id: &str, - contains: Option<&str>, - new_format: DocxStyle, - ) -> Result { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut updated = 0usize; - for op in ops.iter_mut() { - if let DocxOp::Paragraph { text, style } = op { - if contains.map(|needle| text.contains(needle)).unwrap_or(true) { - // Merge properties; prefer provided values over existing - let mut merged = style.clone().unwrap_or(DocxStyle { - font_family: None, font_size: None, bold: None, italic: None, underline: None, - color: None, alignment: None, line_spacing: None, - }); - if new_format.font_family.is_some() { merged.font_family = new_format.font_family.clone(); } - if new_format.font_size.is_some() { merged.font_size = new_format.font_size; } - if new_format.bold.is_some() { merged.bold = new_format.bold; } - if new_format.italic.is_some() { merged.italic = new_format.italic; } - if new_format.underline.is_some() { merged.underline = new_format.underline; } - if new_format.color.is_some() { merged.color = new_format.color.clone(); } - if new_format.alignment.is_some() { merged.alignment = new_format.alignment.clone(); } - if new_format.line_spacing.is_some() { merged.line_spacing = new_format.line_spacing; } - *style = Some(merged); - updated += 1; - } - } - } - if updated > 0 { self.write_docx(doc_id)?; } - Ok(updated) - } - - /// List tables with resolved merges and sizes - pub fn get_tables_json(&self, doc_id: &str) -> Result { - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut tables = Vec::new(); - for (ti, op) in ops.iter().enumerate() { - if let DocxOp::Table { data } = op { - let rows = data.rows.len(); - let cols = data.rows.first().map(|r| r.len()).unwrap_or(0); - tables.push(serde_json::json!({ - "index": ti, - "rows": rows, - "cols": cols, - "col_widths": data.col_widths, - "merges": data.merges, - "cells": data.rows, - })); - } - } - Ok(serde_json::json!({ "tables": tables })) - } - - /// List images with basic metadata - pub fn list_images(&self, doc_id: &str) -> Result { - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut images = Vec::new(); - for (i, op) in ops.iter().enumerate() { - if let DocxOp::Image { width, height, alt_text, .. } = op { - images.push(serde_json::json!({"index": i, "width": width, "height": height, "alt_text": alt_text})); - } - } - Ok(serde_json::json!({"images": images})) - } - - /// List hyperlinks present in the in-memory ops - pub fn list_hyperlinks(&self, doc_id: &str) -> Result { - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - let mut links = Vec::new(); - for (i, op) in ops.iter().enumerate() { - if let DocxOp::Hyperlink { text, url } = op { - links.push(serde_json::json!({"index": i, "text": text, "url": url})); - } - } - Ok(serde_json::json!({"hyperlinks": links})) - } - - /// Summarize fields from document and header/footer XML (best-effort) - pub fn get_fields_summary(&self, doc_id: &str) -> Result { - let metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - let src_file = std::fs::File::open(&metadata.path)?; - let mut archive = ZipArchive::new(src_file)?; - let mut parts = vec!["word/document.xml".to_string()]; - for i in 0..archive.len() { - let name = archive.by_index(i)?.name().to_string(); - if (name.starts_with("word/header") || name.starts_with("word/footer")) && name.ends_with(".xml") { - parts.push(name); - } - } - let mut fields: Vec = Vec::new(); - for part in parts { - if let Ok(mut f) = archive.by_name(&part) { - let mut xml = String::new(); - use std::io::Read as _; - f.read_to_string(&mut xml)?; - for cap in regex::Regex::new(r"([\s\S]*?)")?.captures_iter(&xml) { - let instr = cap.get(1).map(|m| m.as_str().trim()).unwrap_or("").to_string(); - let kind = if instr.contains("TOC") { "TOC" } else if instr.contains("PAGE") { "PAGE" } else if instr.contains("NUMPAGES") { "NUMPAGES" } else { "OTHER" }; - fields.push(serde_json::json!({"part": part, "instruction": instr, "kind": kind})); - } - } - } - Ok(serde_json::json!({"fields": fields})) - } - - /// Remove personal info (best-effort): clear in-memory metadata and scrub core.xml if present - pub fn strip_personal_info(&mut self, doc_id: &str) -> Result<()> { - let meta = self.documents.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - meta.author = None; meta.title = None; meta.subject = None; - // Try to scrub docProps/core.xml - let src_file = std::fs::File::open(&meta.path)?; - let mut archive = ZipArchive::new(src_file)?; - let mut core_xml: Option = None; - if let Ok(mut f) = archive.by_name("docProps/core.xml") { - let mut xml = String::new(); - use std::io::Read as _; - f.read_to_string(&mut xml)?; - // crude replacements - xml = regex::Regex::new(r".*?")?.replace_all(&xml, "").into_owned(); - xml = regex::Regex::new(r".*?")?.replace_all(&xml, "").into_owned(); - xml = regex::Regex::new(r".*?")?.replace_all(&xml, "").into_owned(); - xml = regex::Regex::new(r".*?")?.replace_all(&xml, "").into_owned(); - core_xml = Some(xml); - } - if core_xml.is_none() { return Ok(()); } - // Repack archive with updated core.xml - let src_file = std::fs::File::open(&meta.path)?; - let mut archive = ZipArchive::new(src_file)?; - let temp_path = meta.path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "docProps/core.xml" { - writer.write_all(core_xml.as_ref().unwrap().as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - writer.finish()?; - std::fs::rename(&temp_path, &meta.path)?; - Ok(()) - } - - /// Update document core properties stored in our metadata (best-effort) - pub fn set_document_properties( - &mut self, - doc_id: &str, - title: Option, - subject: Option, - author: Option, - ) -> Result<()> { - let meta = self.documents.get_mut(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - if let Some(t) = title { meta.title = Some(t); } - if let Some(s) = subject { meta.subject = Some(s); } - if let Some(a) = author { meta.author = Some(a); } - Ok(()) - } - - pub fn get_document_properties_json(&self, doc_id: &str) -> Result { - let meta = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - Ok(serde_json::json!({ - "title": meta.title, - "subject": meta.subject, - "author": meta.author, - "created_at": meta.created_at, - "modified_at": meta.modified_at, - })) - } - - /// Insert a paragraph after the first heading that matches `heading_text` - pub fn insert_after_heading(&mut self, doc_id: &str, heading_text: &str, text: &str) -> Result { - self.ensure_modifiable(doc_id)?; - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - if let Some(pos) = ops.iter().position(|op| matches!(op, DocxOp::Heading { text: t, .. } if t == heading_text)) { - ops.insert(pos + 1, DocxOp::Paragraph { text: text.to_string(), style: None }); - self.write_docx(doc_id)?; - return Ok(true); - } - Ok(false) - } - - /// Remove external hyperlinks (basic sanitizer) - pub fn sanitize_external_links(&mut self, doc_id: &str) -> Result { - self.ensure_modifiable(doc_id)?; - let removed = { - let ops = self.in_memory_ops.get_mut(doc_id).unwrap(); - let before = ops.len(); - ops.retain(|op| match op { - DocxOp::Hyperlink { url, .. } => { - let lower = url.to_lowercase(); - !(lower.starts_with("http://") || lower.starts_with("https://")) - } - _ => true, - }); - before.saturating_sub(ops.len()) - }; - self.write_docx(doc_id)?; - Ok(removed) - } - - /// Redact text using advanced find/replace with a block character - pub fn redact_text(&mut self, doc_id: &str, pattern: &str, use_regex: bool, whole_word: bool, case_sensitive: bool) -> Result { - self.find_and_replace_advanced(doc_id, pattern, "█", case_sensitive, whole_word, use_regex) - } - - 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)?; - } - self.in_memory_ops.remove(doc_id); - - info!("Closed document {}", doc_id); - Ok(()) - } - - pub fn list_documents(&self) -> Vec { - self.documents.values().cloned().collect() - } - - pub fn temp_dir_path(&self) -> PathBuf { - self.temp_dir.clone() - } - - pub fn get_storage_info(&self) -> Result { - use std::time::UNIX_EPOCH; - let mut total_bytes: u64 = 0; - let mut file_count: u64 = 0; - let mut oldest: Option = None; - let mut newest: Option = None; - if self.temp_dir.exists() { - for entry in walkdir::WalkDir::new(&self.temp_dir).into_iter().filter_map(|e| e.ok()) { - if entry.file_type().is_file() { - file_count += 1; - if let Ok(meta) = entry.metadata() { - total_bytes = total_bytes.saturating_add(meta.len()); - if let Ok(modified) = meta.modified() { - if let Ok(secs) = modified.duration_since(UNIX_EPOCH) { - let ts = secs.as_secs(); - oldest = Some(oldest.map_or(ts, |o| o.min(ts))); - newest = Some(newest.map_or(ts, |n| n.max(ts))); - } - } - } - } - } - } - Ok(serde_json::json!({ - "success": true, - "storage": { - "base_dir": self.temp_dir, - "file_count": file_count, - "total_bytes": total_bytes, - "oldest_modified": oldest, - "newest_modified": newest, - } - })) - } -} - -#[derive(Debug, Clone)] -enum DocxOp { - Paragraph { text: String, style: Option }, - Heading { text: String, style: String }, - Table { data: TableData }, - List { items: Vec, ordered: bool }, - ListItem { text: String, level: usize, ordered: bool }, - PageBreak, - Header(String), - Footer(String), - Image { data: Vec, width: u32, height: u32, alt_text: Option }, - Hyperlink { text: String, url: String }, - SectionBreak { page_size: Option, orientation: Option, margins: Option }, - Toc { from_level: usize, to_level: usize, right_align_dots: bool }, - BookmarkAfterHeading { heading_text: String, name: String }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MarginsSpec { - pub top: Option, - pub bottom: Option, - pub left: Option, - pub right: Option, -} - -impl DocxHandler { - fn ensure_modifiable(&self, doc_id: &str) -> Result<()> { - if !self.in_memory_ops.contains_key(doc_id) { - anyhow::bail!("Modifications are supported only for documents created by this server (doc_id: {})", doc_id); - } - Ok(()) - } - - fn write_docx(&self, doc_id: &str) -> Result<()> { - let metadata = self.documents.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {}", doc_id))?; - let ops = self.in_memory_ops.get(doc_id) - .ok_or_else(|| anyhow::anyhow!("No in-memory ops for document: {}", doc_id))?; - - let mut docx = Docx::new(); - let mut header_text: Option = None; - let mut footer_text: Option = None; - - for op in ops { - match op { - DocxOp::Paragraph { text, style } => { - let mut run = Run::new().add_text(text); - if let Some(st) = style { - if let Some(size) = st.font_size { run = run.size(size); } - if st.bold == Some(true) { run = run.bold(); } - if st.italic == Some(true) { run = run.italic(); } - if st.underline == Some(true) { run = run.underline("single"); } - if let Some(color) = &st.color { run = run.color(color.clone()); } - } - let para = Paragraph::new().add_run(run); - docx = docx.add_paragraph(para); - } - DocxOp::Heading { text, style } => { - let para = Paragraph::new().add_run(Run::new().add_text(text)).style(style); - docx = docx.add_paragraph(para); - } - DocxOp::Table { data } => { - let col_count = data.rows.get(0).map(|r| r.len()).unwrap_or(0); - // Note: docx-rs Table::new takes rows, not grid. We'll add rows then (optionally) rely on defaults. - let mut table = Table::new(vec![]); - - // Note: We rely on XML post-processing to inject tblGrid widths when feature enabled. - - // Pre-compute merge coverage map (best-effort) - use std::collections::HashSet; - let mut covered: HashSet<(usize, usize)> = HashSet::new(); - let mut topleft: HashSet<(usize, usize)> = HashSet::new(); - if let Some(merges) = &data.merges { - for m in merges { - topleft.insert((m.row, m.col)); - for dr in 0..m.row_span.max(1) { - for dc in 0..m.col_span.max(1) { - covered.insert((m.row + dr, m.col + dc)); - } - } - } - } - - let has_header = data.headers.as_ref().map(|h| !h.is_empty()).unwrap_or(false); - for (ri, row) in data.rows.iter().enumerate() { - let mut cells: Vec = Vec::new(); - for (ci, cell_text) in row.iter().enumerate() { - let pos = (ri, ci); - // Only render text in top-left cell of a merge region; others empty - let text_to_render = if covered.contains(&pos) && !topleft.contains(&pos) { "" } else { cell_text.as_str() }; - let mut para = Paragraph::new().add_run(Run::new().add_text(text_to_render)); - if has_header && ri == 0 { - // Mark first row as header style; post-processing will add style definition - para = para.style("TableHeader"); - } - let cell = TableCell::new().add_paragraph(para); - cells.push(cell); - } - while cells.len() < col_count { cells.push(TableCell::new()); } - table = table.add_row(TableRow::new(cells)); - } - docx = docx.add_table(table); - } - DocxOp::List { items, ordered } => { - // Use separate numbering ids for ordered vs unordered so we can post-process numbering.xml - let (abstract_id, concrete_id) = if *ordered { (10usize, 11usize) } else { (20usize, 21usize) }; - docx = docx - .add_abstract_numbering(docx_rs::AbstractNumbering::new(abstract_id)) - .add_numbering(docx_rs::Numbering::new(concrete_id, abstract_id)); - for item in items { - let para = Paragraph::new() - .add_run(Run::new().add_text(item)) - .numbering(NumberingId::new(concrete_id), IndentLevel::new(0)); - docx = docx.add_paragraph(para); - } - } - DocxOp::ListItem { text, level, ordered } => { - let (abstract_id, concrete_id) = if *ordered { (10usize, 11usize) } else { (20usize, 21usize) }; - docx = docx - .add_abstract_numbering(docx_rs::AbstractNumbering::new(abstract_id)) - .add_numbering(docx_rs::Numbering::new(concrete_id, abstract_id)); - let para = Paragraph::new() - .add_run(Run::new().add_text(text)) - .numbering(NumberingId::new(concrete_id), IndentLevel::new(*level)); - docx = docx.add_paragraph(para); - } - DocxOp::PageBreak => { - let para = Paragraph::new().add_run(Run::new().add_break(BreakType::Page)); - docx = docx.add_paragraph(para); - } - DocxOp::Header(text) => { header_text = Some(text.clone()); } - DocxOp::Footer(text) => { footer_text = Some(text.clone()); } - DocxOp::Image { data, width, height, alt_text: _ } => { - let run = Run::new(); - let pic = Pic::new_with_dimensions(data.clone(), *width, *height); - let para = Paragraph::new().add_run(run.add_image(pic)); - docx = docx.add_paragraph(para); - } - DocxOp::Hyperlink { text, url } => { - let link = Hyperlink::new(url, HyperlinkType::External) - .add_run(Run::new().add_text(text).color("0000FF").underline("single")); - let para = Paragraph::new().add_hyperlink(link); - docx = docx.add_paragraph(para); - } - DocxOp::SectionBreak { .. } => { - // Best-effort: denote a section break with a page break - let para = Paragraph::new().add_run(Run::new().add_break(BreakType::Page)); - docx = docx.add_paragraph(para); - } - DocxOp::Toc { from_level, to_level, right_align_dots } => { - // Insert a recognizable placeholder paragraph for TOC post-processing - let text = format!("__TOC__ FROM:{} TO:{} DOTS:{}", from_level, to_level, right_align_dots); - let para = Paragraph::new().add_run(Run::new().add_text(text)); - docx = docx.add_paragraph(para); - } - DocxOp::BookmarkAfterHeading { heading_text, name } => { - // Insert a marker paragraph that we will convert to a bookmark - let text = format!("__BOOKMARK__ '{}' '{}'" , heading_text, name); - let para = Paragraph::new().add_run(Run::new().add_text(&text)); - docx = docx.add_paragraph(para); - } - } - } - - if let Some(h) = header_text { - let header = Header::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text(h))); - docx = docx.header(header); - } - if let Some(f) = footer_text { - let footer = Footer::new().add_paragraph(Paragraph::new().add_run(Run::new().add_text(f))); - docx = docx.footer(footer); - } - - let file = File::create(&metadata.path)?; - docx.build().pack(file)?; - - // Optionally post-process to inject high-fidelity XML - #[cfg(feature = "hi-fidelity-tables")] - { - self.apply_table_xml_properties(&metadata.path, ops)?; - } - #[cfg(feature = "hi-fidelity-styles")] - { - self.apply_styles_xml_properties(&metadata.path)?; - } - #[cfg(feature = "hi-fidelity-lists")] - { - self.apply_numbering_xml_properties(&metadata.path, ops)?; - } - #[cfg(feature = "hi-fidelity-sections")] - { - self.apply_section_xml_properties(&metadata.path, ops)?; - } - #[cfg(feature = "hi-fidelity-toc")] - { - self.apply_toc_xml_properties(&metadata.path)?; - } - #[cfg(feature = "hi-fidelity-bookmarks")] - { - self.apply_bookmarks_xml_properties(&metadata.path)?; - } - Ok(()) - } -} - -#[cfg(feature = "hi-fidelity-tables")] -impl DocxHandler { - fn apply_table_xml_properties(&self, docx_path: &Path, ops: &Vec) -> Result<()> { - // Open existing archive - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - - // Read document.xml into memory - let mut document_xml = String::new(); - { - let mut f = archive.by_name("word/document.xml")?; - use std::io::Read as _; - f.read_to_string(&mut document_xml)?; - } - - // Count tables and build a merge map per table based on ops order - // We assume each DocxOp::Table corresponds to a in order. - let mut table_merge_specs: Vec<(Option>, Option>)> = Vec::new(); - for op in ops.iter() { - if let DocxOp::Table { data } = op { - table_merge_specs.push((data.col_widths.clone(), data.merges.clone())); - } - } - - if table_merge_specs.is_empty() { - return Ok(()); - } - - // Perform a minimal XML manipulation using string operations to inject gridSpan/vMerge - // This is a best-effort approach and assumes simple structure generated by docx-rs. - // Strategy: - // - Iterate through each block sequentially. - // - Within each table, iterate rows and cells; when a merge starts at (r,c), add w:gridSpan and/or w:vMerge="restart". - // - For cells covered by vertical continuation, set w:vMerge="continue" and remove text if present. - // - If col_widths provided, ensure a with entries exists. - - // Split tables - let mut output = String::new(); - let mut rest = document_xml.as_str(); - let mut tbl_index = 0usize; - while let Some(start) = rest.find("") { - let (tbl_block, tail) = after_head.split_at(end + "".len()); - let processed = self.process_single_table_xml(tbl_block, table_merge_specs.get(tbl_index))?; - output.push_str(&processed); - rest = tail; - tbl_index += 1; - } else { - // Malformed; break - output.push_str(after_head); - rest = ""; - break; - } - } - output.push_str(rest); - - if output != document_xml { - // Rebuild archive with modified document.xml - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - writer.start_file(name.clone(), options)?; - use std::io::{Read as _, Write as _}; - if name == "word/document.xml" { - writer.write_all(output.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - } - - Ok(()) - } - - fn process_single_table_xml(&self, tbl_xml: &str, spec: Option<&(Option>, Option>)>) -> Result { - if spec.is_none() { return Ok(tbl_xml.to_string()); } - let (col_widths, merges_opt) = spec.unwrap(); - let mut out = tbl_xml.to_string(); - - // Ensure tblGrid - if let Some(widths) = col_widths { - if !widths.is_empty() { - if !out.contains(" if present, else right after - if let Some(pr_end) = out.find("") { - let insert_pos = pr_end + "".len(); - let grid_xml = self.render_tbl_grid(widths); - out.insert_str(insert_pos, &grid_xml); - } else if let Some(tbl_start_end) = out.find(">") { - // after opening - let insert_pos = tbl_start_end + 1; - let grid_xml = self.render_tbl_grid(widths); - out.insert_str(insert_pos, &grid_xml); - } - } else { - // Replace existing grid (supports normal and self-closing forms) - let grid_xml = self.render_tbl_grid(widths); - if let Some(gstart) = out.find("") { - let end_abs = gstart + self_close + 2; // include "/>" - out.replace_range(gstart..end_abs, &grid_xml); - } else if let Some(gend) = rel.find("") { - let gend_abs = gstart + gend + "".len(); - out.replace_range(gstart..gend_abs, &grid_xml); - } - } - } - } - } - - // Apply merges - if let Some(merges) = merges_opt { - // Tokenize rows and cells sequentially best-effort - let mut ri = 0usize; - let mut cursor = 0usize; - while let Some(tr_start_off) = out[cursor..].find("") { - let tr_end = tr_start + tr_end_rel + "".len(); - let mut tr_block = out[tr_start..tr_end].to_string(); - - // Walk cells - let mut ci = 0usize; - let mut tr_cursor = 0usize; - while let Some(tc_start_off) = tr_block[tr_cursor..].find("") { - let tc_end = tc_start + tc_end_rel + "".len(); - let mut tc_block = tr_block[tc_start..tc_end].to_string(); - - // Determine merge action for this cell - let mut grid_span: Option = None; - let mut vmerge: Option<&'static str> = None; // "restart" or "continue" - for m in merges { - if m.row == ri && m.col == ci { - if m.col_span > 1 { grid_span = Some(m.col_span); } - if m.row_span > 1 { vmerge = Some("restart"); } - } else if m.col == ci && ri > m.row && ri < m.row + m.row_span && ci >= m.col && ci < m.col + m.col_span { - // vertically covered cell - if m.row_span > 1 { vmerge = Some("continue"); } - } - } - - if grid_span.is_some() || vmerge.is_some() { - // Ensure exists - if let Some(pr_start) = tc_block.find("") { - let insert_at = pr_start + "".len(); - let mut props = String::new(); - if let Some(span) = grid_span { props.push_str(&format!("", span)); } - if let Some(vm) = vmerge { props.push_str(&format!("", vm)); } - tc_block.insert_str(insert_at, &props); - } else { - // Insert tcPr after - if let Some(tc_open_end) = tc_block.find(">") { - let insert_at = tc_open_end + 1; - let mut props = String::new(); - props.push_str(""); - if let Some(span) = grid_span { props.push_str(&format!("", span)); } - if let Some(vm) = vmerge { props.push_str(&format!("", vm)); } - props.push_str(""); - tc_block.insert_str(insert_at, &props); - } - } - } - - // Replace back this cell - tr_block.replace_range(tc_start..tc_end, &tc_block); - tr_cursor = tc_start + tc_block.len(); - ci += 1; - } else { break; } - } - - // Replace back this row - out.replace_range(tr_start..tr_end, &tr_block); - cursor = tr_start + tr_block.len(); - ri += 1; - } else { break; } - } - } - - Ok(out) - } - - fn render_tbl_grid(&self, widths: &Vec) -> String { - let mut s = String::from(""); - for w in widths.iter() { - s.push_str(&format!("", w)); - } - s.push_str(""); - s - } -} - -#[cfg(feature = "hi-fidelity-toc")] -impl DocxHandler { - fn apply_toc_xml_properties(&self, docx_path: &Path) -> Result<()> { - // Replace any __TOC__ placeholder paragraph with a field code TOC - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - let mut document_xml = String::new(); - { - let mut f = archive.by_name("word/document.xml")?; - use std::io::Read as _; - f.read_to_string(&mut document_xml)?; - } - if !document_xml.contains("__TOC__") { return Ok(()); } - - // Simple replacement: any paragraph containing __TOC__ becomes a standard TOC field - let toc_field_runs = r#" - - - TOC \o "1-3" \h \z \u - - Table of Contents - - -"#; - document_xml = document_xml.replace("__TOC__", ""); - // Replace the whole paragraph when marker is present - // Crude but effective: replace the first parent .. that contained the token - while let Some(pos) = document_xml.find("__TOC__") { // unlikely since we replaced above, but loop safe - // Fallback: just remove token - document_xml.replace_range(pos..pos+7, ""); - } - // If there was at least one token originally, ensure we have one TOC block appended at top - if let Some(body_pos) = document_xml.find("") { - let insert_at = body_pos + "".len(); - document_xml.insert_str(insert_at, toc_field_runs); - } - - // Write back - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "word/document.xml" { - writer.write_all(document_xml.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - Ok(()) - } -} - -#[cfg(feature = "hi-fidelity-bookmarks")] -impl DocxHandler { - fn apply_bookmarks_xml_properties(&self, docx_path: &Path) -> Result<()> { - // Convert paragraphs with __BOOKMARK__ 'Heading' 'Name' into bookmarkStart/End around following paragraph - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - let mut document_xml = String::new(); - { - let mut f = archive.by_name("word/document.xml")?; - use std::io::Read as _; - f.read_to_string(&mut document_xml)?; - } - if !document_xml.contains("__BOOKMARK__") { return Ok(()); } - - // Naive approach: remove marker paragraph entirely. - while let Some(p_start) = document_xml.find("") { - if let Some(tok) = document_xml[p_start..].find("__BOOKMARK__") { - let abs = p_start + tok; - // Find paragraph bounds - if let Some(p_end_rel) = document_xml[p_start..].find("") { - let p_end = p_start + p_end_rel + "".len(); - // Remove the marker paragraph - document_xml.replace_range(p_start..p_end, ""); - continue; - } - } - break; - } - - // Write back - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "word/document.xml" { - writer.write_all(document_xml.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - Ok(()) - } -} - -#[cfg(feature = "hi-fidelity-styles")] -impl DocxHandler { - fn apply_styles_xml_properties(&self, docx_path: &Path) -> Result<()> { - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - - // Read or initialize styles.xml - let mut styles_xml = String::new(); - let mut has_styles = false; - if let Ok(mut f) = archive.by_name("word/styles.xml") { - use std::io::Read as _; - f.read_to_string(&mut styles_xml)?; - has_styles = true; - } else { - styles_xml = String::from("\ -"); - } - - if !styles_xml.contains("w:styleId=\"TableHeader\"") { - let style_def = concat!( - "", - "", - "", - "", - "", - "", - "" - ); - if let Some(pos) = styles_xml.rfind("") { - styles_xml.insert_str(pos, style_def); - } - } - - // Repack archive with updated styles.xml - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "word/styles.xml" { - writer.write_all(styles_xml.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - - if !has_styles { - // If styles.xml was missing originally, ensure it is added - writer.start_file("word/styles.xml".to_string(), options)?; - use std::io::Write as _; - writer.write_all(styles_xml.as_bytes())?; - } - - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - Ok(()) - } -} - -#[cfg(feature = "hi-fidelity-lists")] -impl DocxHandler { - fn apply_numbering_xml_properties(&self, docx_path: &Path, ops: &Vec) -> Result<()> { - // Determine which list types are used - let mut need_ordered = false; - let mut need_unordered = false; - for op in ops.iter() { - match op { - DocxOp::List { ordered, .. } => { if *ordered { need_ordered = true; } else { need_unordered = true; } } - DocxOp::ListItem { ordered, .. } => { if *ordered { need_ordered = true; } else { need_unordered = true; } } - _ => {} - } - } - if !need_ordered && !need_unordered { return Ok(()); } - - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - - // Read numbering.xml - let mut numbering_xml = String::new(); - { - let mut f = archive.by_name("word/numbering.xml").map_err(|_| anyhow::anyhow!("numbering.xml not found; ensure lists are added before calling"))?; - use std::io::Read as _; - f.read_to_string(&mut numbering_xml)?; - } - - // Ensure abstractNum for ordered (10) and unordered (20) - if need_ordered && !numbering_xml.contains("w:abstractNumId=\"10\"") { - let block = self.make_abstract_num_block(10, false); - if let Some(pos) = numbering_xml.find("") { - numbering_xml.insert_str(pos, &block); - } - } - if need_unordered && !numbering_xml.contains("w:abstractNumId=\"20\"") { - let block = self.make_abstract_num_block(20, true); - if let Some(pos) = numbering_xml.find("") { - numbering_xml.insert_str(pos, &block); - } - } - - // Write back - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "word/numbering.xml" { - writer.write_all(numbering_xml.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - Ok(()) - } - - fn make_abstract_num_block(&self, abstract_id: usize, bullet: bool) -> String { - let mut s = format!("", abstract_id); - for lvl in 0..9 { - let (fmt, txt) = if bullet { ("bullet", "•") } else { ("decimal", match lvl { 0 => "%1.", 1 => "%2.", 2 => "%3.", 3 => "%4.", 4 => "%5.", 5 => "%6.", 6 => "%7.", 7 => "%8.", _ => "%9." }) }; - let lvl_text = if bullet { "•".to_string() } else { txt.to_string() }; - s.push_str(&format!( - concat!( - "", - "", - "", - "", - "", - "", - "" - ), - lvl=lvl, - fmt=fmt, - lvl_text=lvl_text, - left=(lvl as i32 + 1) * 720, - hang=360, - )); - } - s.push_str(""); - s - } -} - -#[cfg(feature = "hi-fidelity-sections")] -impl DocxHandler { - fn apply_section_xml_properties(&self, docx_path: &Path, ops: &Vec) -> Result<()> { - // Use the last section break spec, if any - let mut last_spec: Option<(Option, Option, Option)> = None; - for op in ops.iter() { - if let DocxOp::SectionBreak { page_size, orientation, margins } = op { - last_spec = Some((page_size.clone(), orientation.clone(), margins.clone())); - } - } - if last_spec.is_none() { return Ok(()); } - let (page_size, orientation, margins) = last_spec.unwrap(); - - let (mut w, mut h) = match page_size.as_deref() { - Some("Letter") => (12240i32, 15840i32), // 8.5x11 in - _ => (11906i32, 16838i32), // default A4 210x297mm - }; - if orientation.as_deref() == Some("landscape") { - std::mem::swap(&mut w, &mut h); - } - let margins = margins.unwrap_or(MarginsSpec { top: Some(1.0), bottom: Some(1.0), left: Some(1.0), right: Some(1.0) }); - let to_twips = |opt: Option| -> i32 { ((opt.unwrap_or(1.0) * 1440.0).round() as i32).max(0) }; - let mt = to_twips(margins.top); - let mb = to_twips(margins.bottom); - let ml = to_twips(margins.left); - let mr = to_twips(margins.right); - - let sect_pr = if orientation.as_deref() == Some("landscape") { - format!("", w, h, mt, mb, ml, mr) - } else { - format!("", w, h, mt, mb, ml, mr) - }; - - let src_file = std::fs::File::open(docx_path)?; - let mut archive = ZipArchive::new(src_file)?; - let mut document_xml = String::new(); - { - let mut f = archive.by_name("word/document.xml")?; - use std::io::Read as _; - f.read_to_string(&mut document_xml)?; - } - - if let Some(pos) = document_xml.rfind("") { - // Replace existing sectPr if present near end - if let Some(existing_start_rel) = document_xml[..pos].rfind(""); - if let Some(closing_rel) = closing_rel { - let closing_abs = existing_start_rel + closing_rel + "".len(); - document_xml.replace_range(existing_start_rel..closing_abs, §_pr); - } else { - document_xml.insert_str(pos, §_pr); - } - } else { - document_xml.insert_str(pos, §_pr); - } - } - - // Write back - let temp_path = docx_path.with_extension("docx.tmp"); - let dst_file = std::fs::File::create(&temp_path)?; - let mut writer = ZipWriter::new(dst_file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored); - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - use std::io::{Read as _, Write as _}; - writer.start_file(name.clone(), options)?; - if name == "word/document.xml" { - writer.write_all(document_xml.as_bytes())?; - } else { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - writer.write_all(&buf)?; - } - } - writer.finish()?; - std::fs::rename(&temp_path, docx_path)?; - Ok(()) - } -} \ No newline at end of file diff --git a/src/docx_tools.rs b/src/docx_tools.rs deleted file mode 100644 index cb3c86e..0000000 --- a/src/docx_tools.rs +++ /dev/null @@ -1,1998 +0,0 @@ -use mcp_core::types::{Tool, CallToolResponse, ToolResponseContent, TextContent}; -use crate::response::{ToolOutcome, ErrorCode}; -#[cfg(feature = "advanced-docx")] -use crate::advanced_docx::AdvancedDocxHandler; -use crate::security::{SecurityConfig, SecurityMiddleware}; -use crate::docx_handler::{DocxHandler, DocxStyle, TableData}; -use crate::converter::DocumentConverter; - -use serde_json::{json, Value}; -use tracing::{info, debug}; -use std::sync::{Arc, RwLock}; -use std::path::PathBuf; - -#[derive(Clone)] -pub struct DocxToolsProvider { - handler: Arc>, - converter: Arc, - #[cfg(feature = "advanced-docx")] - advanced: Arc, - security: Arc, - security_config: SecurityConfig, - templates_dir: PathBuf, -} - -impl DocxToolsProvider { - pub fn new() -> Self { - Self::new_with_security(SecurityConfig::default()) - } - - pub fn new_with_security(security_config: SecurityConfig) -> Self { - Self::new_with_security_and_templates(security_config, PathBuf::from("/templates")) - } - - pub fn new_with_security_and_templates(security_config: SecurityConfig, templates_dir: PathBuf) -> Self { - Self { - handler: Arc::new(RwLock::new(DocxHandler::new().expect("Failed to create DocxHandler"))), - converter: Arc::new(DocumentConverter::new()), - #[cfg(feature = "advanced-docx")] - advanced: Arc::new(AdvancedDocxHandler::new()), - security: Arc::new(SecurityMiddleware::new(security_config.clone())), - security_config, - templates_dir, - } - } - - /// Create a provider that stores temporary documents under the provided base directory - pub fn with_base_dir>(base_dir: P) -> Self { - Self::with_base_dir_and_security(base_dir, SecurityConfig::default()) - } - - /// Create a provider with a base directory and explicit security config - pub fn with_base_dir_and_security>(base_dir: P, security_config: SecurityConfig) -> Self { - Self { - handler: Arc::new(RwLock::new(DocxHandler::new_with_base_dir(base_dir).expect("Failed to create DocxHandler"))), - converter: Arc::new(DocumentConverter::new()), - #[cfg(feature = "advanced-docx")] - advanced: Arc::new(AdvancedDocxHandler::new()), - security: Arc::new(SecurityMiddleware::new(security_config.clone())), - security_config, - templates_dir: PathBuf::from("/templates"), - } - } -} - -impl DocxToolsProvider { - pub async fn list_tools(&self) -> Vec { - let mut all_tools = vec![ - Tool { - name: "create_document".to_string(), - description: Some("Create a new empty DOCX document".to_string()), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - annotations: None, - }, - Tool { - name: "open_document".to_string(), - description: Some("Open an existing DOCX document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the DOCX file to open" - } - }, - "required": ["path"] - }), - annotations: None, - }, - Tool { - name: "add_paragraph".to_string(), - description: Some("Add a paragraph with optional styling to the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "text": { - "type": "string", - "description": "Text content of the paragraph" - }, - "style": { - "type": "object", - "properties": { - "font_family": {"type": "string"}, - "font_size": {"type": "integer"}, - "bold": {"type": "boolean"}, - "italic": {"type": "boolean"}, - "underline": {"type": "boolean"}, - "color": {"type": "string"}, - "alignment": { - "type": "string", - "enum": ["left", "center", "right", "justify"] - }, - "line_spacing": {"type": "number"} - } - } - }, - "required": ["document_id", "text"] - }), - annotations: None, - }, - Tool { - name: "add_heading".to_string(), - description: Some("Add a heading to the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "text": { - "type": "string", - "description": "Heading text" - }, - "level": { - "type": "integer", - "description": "Heading level (1-6)", - "minimum": 1, - "maximum": 6 - } - }, - "required": ["document_id", "text", "level"] - }), - annotations: None, - }, - Tool { - name: "add_table".to_string(), - description: Some("Add a table to the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "rows": { - "type": "array", - "description": "Table rows, each containing an array of cell values", - "items": { - "type": "array", - "items": {"type": "string"} - } - }, - "headers": { - "type": "array", - "description": "Optional header row", - "items": {"type": "string"} - }, - "border_style": { - "type": "string", - "description": "Table border style" - }, - "col_widths": { - "type": "array", - "description": "Approximate column widths in pixels", - "items": {"type": "integer"} - }, - "cell_shading": { - "type": "string", - "description": "Cell shading color (hex RGB)" - }, - "merges": { - "type": "array", - "description": "Cell merge specs", - "items": { - "type": "object", - "properties": { - "row": {"type": "integer"}, - "col": {"type": "integer"}, - "row_span": {"type": "integer"}, - "col_span": {"type": "integer"} - }, - "required": ["row", "col"] - } - } - }, - "required": ["document_id", "rows"] - }), - annotations: None, - }, - Tool { - name: "add_section_break".to_string(), - description: Some("Insert a section break with optional page setup".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "page_size": {"type": "string", "description": "A4, Letter, ..."}, - "orientation": {"type": "string", "enum": ["portrait", "landscape"]}, - "margins": { - "type": "object", - "properties": { - "top": {"type": "number"}, - "bottom": {"type": "number"}, - "left": {"type": "number"}, - "right": {"type": "number"} - } - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "add_list".to_string(), - description: Some("Add a bulleted or numbered list to the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "items": { - "type": "array", - "description": "List items", - "items": {"type": "string"} - }, - "ordered": { - "type": "boolean", - "description": "Whether the list is numbered (true) or bulleted (false)", - "default": false - } - }, - "required": ["document_id", "items"] - }), - annotations: None, - }, - Tool { - name: "add_list_item".to_string(), - description: Some("Add a single list item with a specific level".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "text": {"type": "string"}, - "level": {"type": "integer", "minimum": 0, "default": 0}, - "ordered": {"type": "boolean", "default": false} - }, - "required": ["document_id", "text"] - }), - annotations: None, - }, - Tool { - name: "add_page_break".to_string(), - description: Some("Add a page break to the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "insert_toc".to_string(), - description: Some("Insert a Table of Contents placeholder (hi-fidelity can inject TOC field)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "from_level": {"type": "integer", "default": 1}, - "to_level": {"type": "integer", "default": 3}, - "right_align_dots": {"type": "boolean", "default": true} - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "insert_bookmark_after_heading".to_string(), - description: Some("Insert a bookmark immediately after the first matching heading".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "heading_text": {"type": "string"}, - "name": {"type": "string"} - }, - "required": ["document_id", "heading_text", "name"] - }), - annotations: None, - }, - Tool { - name: "set_header".to_string(), - description: Some("Set the document header".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "text": { - "type": "string", - "description": "Header text" - } - }, - "required": ["document_id", "text"] - }), - annotations: None, - }, - Tool { - name: "set_footer".to_string(), - description: Some("Set the document footer".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "text": { - "type": "string", - "description": "Footer text" - } - }, - "required": ["document_id", "text"] - }), - annotations: None, - }, - Tool { - name: "set_page_numbering".to_string(), - description: Some("Set a simple page numbering text in header or footer".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "location": {"type": "string", "enum": ["header", "footer"], "default": "footer"}, - "template": {"type": "string", "description": "e.g., 'Page {PAGE} of {PAGES}'"} - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "embed_page_number_fields".to_string(), - description: Some("Replace placeholder 'Page {PAGE} of {PAGES}' with Word field codes (best-effort)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"} - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "add_image".to_string(), - description: Some("Insert an image into the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "data_base64": {"type": "string", "description": "Base64-encoded image data (PNG/JPEG)"}, - "width": {"type": "integer", "description": "Width in pixels"}, - "height": {"type": "integer", "description": "Height in pixels"}, - "alt_text": {"type": "string"} - }, - "required": ["document_id", "data_base64"] - }), - annotations: None, - }, - Tool { - name: "add_hyperlink".to_string(), - description: Some("Insert a hyperlink into the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "text": {"type": "string"}, - "url": {"type": "string"} - }, - "required": ["document_id", "text", "url"] - }), - annotations: None, - }, - Tool { - name: "find_and_replace".to_string(), - description: Some("Find and replace text in the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "find_text": { - "type": "string", - "description": "Text to find" - }, - "replace_text": { - "type": "string", - "description": "Text to replace with" - } - }, - "required": ["document_id", "find_text", "replace_text"] - }), - annotations: None, - }, - Tool { - name: "find_and_replace_advanced".to_string(), - description: Some("Find/replace with regex, case, whole-word, preserving runs".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "pattern": {"type": "string"}, - "replacement": {"type": "string"}, - "case_sensitive": {"type": "boolean", "default": false}, - "whole_word": {"type": "boolean", "default": false}, - "use_regex": {"type": "boolean", "default": false} - }, - "required": ["document_id", "pattern", "replacement"] - }), - annotations: None, - }, - Tool { - name: "apply_paragraph_format".to_string(), - description: Some("Apply paragraph formatting to paragraphs matching a simple selector".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "contains": {"type": "string", "description": "Substring to match in paragraph text"}, - "format": { - "type": "object", - "properties": { - "font_family": {"type": "string"}, - "font_size": {"type": "integer"}, - "bold": {"type": "boolean"}, - "italic": {"type": "boolean"}, - "underline": {"type": "boolean"}, - "color": {"type": "string"}, - "alignment": {"type": "string"}, - "line_spacing": {"type": "number"} - } - } - }, - "required": ["document_id", "format"] - }), - annotations: None, - }, - Tool { - name: "extract_text".to_string(), - description: Some("Extract all text content from the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_tables".to_string(), - description: Some("List tables with dimensions, merges, and cell content".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "list_images".to_string(), - description: Some("List images with width/height and alt text".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "list_hyperlinks".to_string(), - description: Some("List hyperlinks in the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_fields_summary".to_string(), - description: Some("Summarize Word fields (PAGE, NUMPAGES, TOC) in document and headers/footers".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "strip_personal_info".to_string(), - description: Some("Remove personal info from metadata and core.xml (best-effort)".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_metadata".to_string(), - description: Some("Get document metadata".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "save_document".to_string(), - description: Some("Save the document to a specific path".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "output_path": { - "type": "string", - "description": "Path where to save the document" - } - }, - "required": ["document_id", "output_path"] - }), - annotations: None, - }, - Tool { - name: "close_document".to_string(), - description: Some("Close the document and free resources".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "list_documents".to_string(), - description: Some("List all open documents".to_string()), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - annotations: None, - }, - Tool { - name: "convert_to_pdf".to_string(), - description: Some("Convert a DOCX document to PDF".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document to convert" - }, - "output_path": { - "type": "string", - "description": "Path where to save the PDF" - }, - "prefer_external": { - "type": "boolean", - "description": "Prefer external hi-fidelity converter when available", - "default": false - } - }, - "required": ["document_id", "output_path"] - }), - annotations: None, - }, - Tool { - name: "export_pdf_with_field_refresh".to_string(), - description: Some("Embed page fields then export to PDF (hi-fidelity when available)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "output_path": {"type": "string"}, - "prefer_external": {"type": "boolean", "default": true} - }, - "required": ["document_id", "output_path"] - }), - annotations: None, - }, - Tool { - name: "convert_to_images".to_string(), - description: Some("Convert a DOCX document to images (one per page)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document to convert" - }, - "output_dir": { - "type": "string", - "description": "Directory where to save the images" - }, - "format": { - "type": "string", - "description": "Image format", - "enum": ["png", "jpg", "jpeg"], - "default": "png" - }, - "dpi": { - "type": "integer", - "description": "Resolution in DPI", - "default": 150, - "minimum": 72, - "maximum": 600 - } - }, - "required": ["document_id", "output_dir"] - }), - annotations: None, - }, - Tool { - name: "convert_to_images_with_preference".to_string(), - description: Some("Convert DOCX to images, preferring external hi-fidelity path".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "output_dir": {"type": "string"}, - "format": {"type": "string", "enum": ["png", "jpg", "jpeg"], "default": "png"}, - "dpi": {"type": "integer", "default": 150}, - "prefer_external": {"type": "boolean", "default": true} - }, - "required": ["document_id", "output_dir"] - }), - annotations: None, - }, - // Advanced tools are gated and added only when feature is enabled - - #[cfg(feature = "advanced-docx")] - Tool { - name: "merge_documents".to_string(), - description: Some("Merge multiple DOCX documents into one".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_ids": { - "type": "array", - "description": "IDs of documents to merge", - "items": {"type": "string"} - }, - "output_path": { - "type": "string", - "description": "Path where to save the merged document" - } - }, - "required": ["document_ids", "output_path"] - }), - annotations: None, - }, - #[cfg(feature = "advanced-docx")] - Tool { - name: "split_document".to_string(), - description: Some("Split a document at page breaks".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document to split" - }, - "output_dir": { - "type": "string", - "description": "Directory where to save the split documents" - } - }, - "required": ["document_id", "output_dir"] - }), - annotations: None, - }, - Tool { - name: "get_document_structure".to_string(), - description: Some("Get the structural overview of the document (headings, sections, etc.)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_outline".to_string(), - description: Some("Return heading outline with range_ids".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_ranges".to_string(), - description: Some("Resolve a selector to range_ids (heading:'Text', paragraph[i], table[t].cell[r,c])".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}, "selector": {"type": "string"}}, - "required": ["document_id", "selector"] - }), - annotations: None, - }, - Tool { - name: "replace_range_text".to_string(), - description: Some("Replace text in a paragraph/heading by range_id".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}, "range_id": {"type": "object"}, "text": {"type": "string"}}, - "required": ["document_id", "range_id", "text"] - }), - annotations: None, - }, - Tool { - name: "set_table_cell_text".to_string(), - description: Some("Set text in a table cell by indices".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}, "table_index": {"type": "integer"}, "row": {"type": "integer"}, "col": {"type": "integer"}, "text": {"type": "string"}}, - "required": ["document_id", "table_index", "row", "col", "text"] - }), - annotations: None, - }, - Tool { - name: "get_document_properties".to_string(), - description: Some("Get document properties (title, subject, author, timestamps)".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "set_document_properties".to_string(), - description: Some("Set document properties (title, subject, author)".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "title": {"type": "string"}, - "subject": {"type": "string"}, - "author": {"type": "string"} - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "insert_after_heading".to_string(), - description: Some("Insert a paragraph after the first heading that matches text".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "heading_text": {"type": "string"}, - "text": {"type": "string"} - }, - "required": ["document_id", "heading_text", "text"] - }), - annotations: None, - }, - Tool { - name: "sanitize_external_links".to_string(), - description: Some("Remove external hyperlinks (http/https)".to_string()), - input_schema: json!({ - "type": "object", - "properties": {"document_id": {"type": "string"}}, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "redact_text".to_string(), - description: Some("Redact text using regex/whole-word with █ character".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": {"type": "string"}, - "pattern": {"type": "string"}, - "use_regex": {"type": "boolean", "default": false}, - "whole_word": {"type": "boolean", "default": false}, - "case_sensitive": {"type": "boolean", "default": false} - }, - "required": ["document_id", "pattern"] - }), - annotations: None, - }, - Tool { - name: "analyze_formatting".to_string(), - description: Some("Analyze the formatting used throughout the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "get_word_count".to_string(), - description: Some("Get detailed word count statistics for the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - } - }, - "required": ["document_id"] - }), - annotations: None, - }, - Tool { - name: "search_text".to_string(), - description: Some("Search for text patterns in the document".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "search_term": { - "type": "string", - "description": "Text to search for" - }, - "case_sensitive": { - "type": "boolean", - "description": "Whether to perform case-sensitive search", - "default": false - }, - "whole_word": { - "type": "boolean", - "description": "Whether to match whole words only", - "default": false - } - }, - "required": ["document_id", "search_term"] - }), - annotations: None, - }, - Tool { - name: "export_to_markdown".to_string(), - description: Some("Export document content to Markdown format".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "output_path": { - "type": "string", - "description": "Path where to save the Markdown file" - } - }, - "required": ["document_id", "output_path"] - }), - annotations: None, - }, - Tool { - name: "export_to_html".to_string(), - description: Some("Export document content to HTML format".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "document_id": { - "type": "string", - "description": "ID of the document" - }, - "output_path": { - "type": "string", - "description": "Path where to save the HTML file" - } - }, - "required": ["document_id", "output_path"] - }), - annotations: None, - }, - Tool { - name: "get_security_info".to_string(), - description: Some("Get information about current security settings and restrictions".to_string()), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - annotations: None, - }, - Tool { - name: "get_storage_info".to_string(), - description: Some("Get information about temporary storage usage".to_string()), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - annotations: None, - }, - Tool { - name: "list_templates".to_string(), - description: Some("List available document templates from the templates directory".to_string()), - input_schema: json!({ - "type": "object", - "properties": {}, - "required": [] - }), - annotations: None, - }, - Tool { - name: "open_template".to_string(), - description: Some("Open a template document by name from the templates directory".to_string()), - input_schema: json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Template file name (e.g., 'nda_template.docx')" - } - }, - "required": ["name"] - }), - annotations: None, - }, - Tool { - name: "generate_from_template".to_string(), - description: Some( - "Generate a new document from a template by filling placeholders like {{FIELD_NAME}} with provided values".to_string() - ), - input_schema: json!({ - "type": "object", - "properties": { - "template_name": { - "type": "string", - "description": "Template file name (e.g., 'nda_template.docx')" - }, - "output_path": { - "type": "string", - "description": "Output DOCX path (e.g., '/out/nda_filled.docx')" - }, - "fields": { - "type": "object", - "description": "Key-value pairs; keys are placeholder names without braces. Example: {\"CLIENT_NAME\": \"Acme Corp\"}" - } - }, - "required": ["template_name", "output_path", "fields"] - }), - annotations: None, - }, - ]; - - // Filter tools based on security configuration - all_tools.retain(|tool| { - self.security_config.is_command_allowed(&tool.name) - }); - - info!("Exposing {} tools (security filtered)", all_tools.len()); - all_tools - } - - pub async fn call_tool(&self, name: &str, arguments: Value) -> CallToolResponse { - debug!("Calling tool: {} with arguments: {:?}", name, arguments); - - // Security check - if let Err(security_error) = self.security.check_command(name, &arguments) { - let err_json = json!({ - "success": false, - "error": format!("Security check failed: {}", security_error), - }); - return CallToolResponse { - content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: err_json.to_string(), annotations: None })], - is_error: Some(true), - meta: None, - }; - } - - let outcome = match name { - "create_document" => { - let mut handler = self.handler.write().unwrap(); - match handler.create_document() { - Ok(doc_id) => ToolOutcome::Created { document_id: doc_id, message: Some("Document created successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "open_document" => { - let path = arguments["path"].as_str().unwrap_or(""); - let mut handler = self.handler.write().unwrap(); - match handler.open_document(&PathBuf::from(path)) { - Ok(doc_id) => ToolOutcome::Created { document_id: doc_id, message: Some(format!("Document opened from {}", path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_paragraph" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - - let style = arguments.get("style").and_then(|s| { - serde_json::from_value::(s.clone()).ok() - }); - - let mut handler = self.handler.write().unwrap(); - match handler.add_paragraph(doc_id, text, style) { - Ok(_) => ToolOutcome::Ok { message: Some("Paragraph added successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_heading" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - let level = arguments["level"].as_u64().unwrap_or(1) as usize; - - let mut handler = self.handler.write().unwrap(); - match handler.add_heading(doc_id, text, level) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Heading level {} added successfully", level)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_table" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let rows = arguments["rows"].as_array() - .map(|rows| { - rows.iter() - .filter_map(|row| { - row.as_array().map(|cells| { - cells.iter() - .filter_map(|cell| cell.as_str().map(String::from)) - .collect() - }) - }) - .collect() - }) - .unwrap_or_else(Vec::new); - - let headers = arguments.get("headers") - .and_then(|h| h.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }); - - let border_style = arguments.get("border_style") - .and_then(|s| s.as_str()) - .map(String::from); - - // Parse merges if provided - let merges = arguments.get("merges").and_then(|v| v.as_array()).map(|arr| { - arr.iter().filter_map(|m| { - m.as_object().map(|o| crate::docx_handler::TableMerge { - row: o.get("row").and_then(|v| v.as_u64()).unwrap_or(0) as usize, - col: o.get("col").and_then(|v| v.as_u64()).unwrap_or(0) as usize, - row_span: o.get("row_span").and_then(|v| v.as_u64()).unwrap_or(1) as usize, - col_span: o.get("col_span").and_then(|v| v.as_u64()).unwrap_or(1) as usize, - }) - }).collect() - }); - - let table_data = TableData { - rows, - headers, - border_style, - col_widths: arguments.get("col_widths").and_then(|v| v.as_array()).map(|arr| arr.iter().filter_map(|x| x.as_u64().map(|n| n as u32)).collect()), - merges, - cell_shading: arguments.get("cell_shading").and_then(|v| v.as_str()).map(|s| s.to_string()), - }; - - let mut handler = self.handler.write().unwrap(); - match handler.add_table(doc_id, table_data) { - Ok(_) => ToolOutcome::Ok { message: Some("Table added successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_section_break" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let page_size = arguments.get("page_size").and_then(|v| v.as_str()); - let orientation = arguments.get("orientation").and_then(|v| v.as_str()); - let margins = arguments.get("margins").and_then(|m| m.as_object()).map(|m| crate::docx_handler::MarginsSpec { - top: m.get("top").and_then(|v| v.as_f64()).map(|v| v as f32), - bottom: m.get("bottom").and_then(|v| v.as_f64()).map(|v| v as f32), - left: m.get("left").and_then(|v| v.as_f64()).map(|v| v as f32), - right: m.get("right").and_then(|v| v.as_f64()).map(|v| v as f32), - }); - - let mut handler = self.handler.write().unwrap(); - match handler.add_section_break(doc_id, page_size, orientation, margins) { - Ok(_) => ToolOutcome::Ok { message: Some("Section break added".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_list" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let items = arguments["items"].as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_else(Vec::new); - let ordered = arguments.get("ordered") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let mut handler = self.handler.write().unwrap(); - match handler.add_list(doc_id, items, ordered) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("{} list added successfully", if ordered { "Ordered" } else { "Unordered" })) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_list_item" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - let level = arguments.get("level").and_then(|v| v.as_u64()).unwrap_or(0) as usize; - let ordered = arguments.get("ordered").and_then(|v| v.as_bool()).unwrap_or(false); - - let mut handler = self.handler.write().unwrap(); - match handler.add_list_item(doc_id, text, level, ordered) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("List item (level {}) added", level)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_page_break" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - let mut handler = self.handler.write().unwrap(); - match handler.add_page_break(doc_id) { - Ok(_) => ToolOutcome::Ok { message: Some("Page break added successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "insert_toc" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let from_level = arguments.get("from_level").and_then(|v| v.as_u64()).unwrap_or(1) as usize; - let to_level = arguments.get("to_level").and_then(|v| v.as_u64()).unwrap_or(3) as usize; - let right_align_dots = arguments.get("right_align_dots").and_then(|v| v.as_bool()).unwrap_or(true); - let mut handler = self.handler.write().unwrap(); - match handler.insert_toc(doc_id, from_level, to_level, right_align_dots) { - Ok(_) => ToolOutcome::Ok { message: Some("TOC placeholder inserted".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "insert_bookmark_after_heading" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let heading_text = arguments["heading_text"].as_str().unwrap_or(""); - let name = arguments["name"].as_str().unwrap_or(""); - let mut handler = self.handler.write().unwrap(); - match handler.insert_bookmark_after_heading(doc_id, heading_text, name) { - Ok(true) => ToolOutcome::Ok { message: Some("Bookmark inserted".into()) }, - Ok(false) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: "Heading not found".into(), hint: None }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "set_header" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - - let mut handler = self.handler.write().unwrap(); - match handler.set_header(doc_id, text) { - Ok(_) => ToolOutcome::Ok { message: Some("Header set successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "set_footer" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - - let mut handler = self.handler.write().unwrap(); - match handler.set_footer(doc_id, text) { - Ok(_) => ToolOutcome::Ok { message: Some("Footer set successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "set_page_numbering" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let location = arguments.get("location").and_then(|v| v.as_str()).unwrap_or("footer"); - let template = arguments.get("template").and_then(|v| v.as_str()); - let mut handler = self.handler.write().unwrap(); - match handler.set_page_numbering(doc_id, location, template) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Page numbering set in {}", location)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "embed_page_number_fields" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.embed_page_number_fields(doc_id) { - Ok(_) => ToolOutcome::Ok { message: Some("Embedded PAGE/NUMPAGES fields (best-effort)".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "add_image" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let data_b64 = arguments["data_base64"].as_str().unwrap_or(""); - let width = arguments.get("width").and_then(|v| v.as_u64()).map(|v| v as u32); - let height = arguments.get("height").and_then(|v| v.as_u64()).map(|v| v as u32); - let alt_text = arguments.get("alt_text").and_then(|v| v.as_str()).map(|s| s.to_string()); - - let image_data = match base64::decode(data_b64) { - Ok(bytes) => bytes, - Err(e) => return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: format!("{{\"success\":false,\"error\":\"invalid base64: {}\"}}", e), annotations: None })], is_error: Some(true), meta: None }, - }; - - let mut handler = self.handler.write().unwrap(); - let image = crate::docx_handler::ImageData { data: image_data, width, height, alt_text }; - match handler.add_image(doc_id, image) { - Ok(_) => ToolOutcome::Ok { message: Some("Image added".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "add_hyperlink" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let text = arguments["text"].as_str().unwrap_or(""); - let url = arguments["url"].as_str().unwrap_or(""); - let mut handler = self.handler.write().unwrap(); - match handler.add_hyperlink(doc_id, text, url) { - Ok(_) => ToolOutcome::Ok { message: Some("Hyperlink added".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "find_and_replace" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let find_text = arguments["find_text"].as_str().unwrap_or(""); - let replace_text = arguments["replace_text"].as_str().unwrap_or(""); - - let mut handler = self.handler.write().unwrap(); - match handler.find_and_replace(doc_id, find_text, replace_text) { - Ok(count) => ToolOutcome::Ok { message: Some(format!("Replaced {} occurrences", count)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "find_and_replace_advanced" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let pattern = arguments["pattern"].as_str().unwrap_or(""); - let replacement = arguments["replacement"].as_str().unwrap_or(""); - let case_sensitive = arguments.get("case_sensitive").and_then(|v| v.as_bool()).unwrap_or(false); - let whole_word = arguments.get("whole_word").and_then(|v| v.as_bool()).unwrap_or(false); - let use_regex = arguments.get("use_regex").and_then(|v| v.as_bool()).unwrap_or(false); - - let mut handler = self.handler.write().unwrap(); - match handler.find_and_replace_advanced(doc_id, pattern, replacement, case_sensitive, whole_word, use_regex) { - Ok(count) => ToolOutcome::Ok { message: Some(format!("Replaced {} occurrences", count)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "apply_paragraph_format" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let contains = arguments.get("contains").and_then(|v| v.as_str()); - let fmt = &arguments["format"]; - let style = DocxStyle { - font_family: fmt.get("font_family").and_then(|v| v.as_str()).map(|s| s.to_string()), - font_size: fmt.get("font_size").and_then(|v| v.as_u64()).map(|v| v as usize), - bold: fmt.get("bold").and_then(|v| v.as_bool()), - italic: fmt.get("italic").and_then(|v| v.as_bool()), - underline: fmt.get("underline").and_then(|v| v.as_bool()), - color: fmt.get("color").and_then(|v| v.as_str()).map(|s| s.to_string()), - alignment: fmt.get("alignment").and_then(|v| v.as_str()).map(|s| s.to_string()), - line_spacing: fmt.get("line_spacing").and_then(|v| v.as_f64()).map(|v| v as f32), - }; - let mut handler = self.handler.write().unwrap(); - match handler.apply_paragraph_format(doc_id, contains, style) { - Ok(count) => ToolOutcome::Ok { message: Some(format!("Updated {} paragraph(s)", count)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "extract_text" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.extract_text(doc_id) { - Ok(text) => ToolOutcome::Text { text }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "get_tables" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.get_tables_json(doc_id) { - Ok(json) => ToolOutcome::Metadata { metadata: json }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "list_images" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.list_images(doc_id) { - Ok(json) => ToolOutcome::Metadata { metadata: json }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "list_hyperlinks" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.list_hyperlinks(doc_id) { - Ok(json) => ToolOutcome::Metadata { metadata: json }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "get_fields_summary" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.get_fields_summary(doc_id) { - Ok(json) => ToolOutcome::Metadata { metadata: json }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "strip_personal_info" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let mut handler = self.handler.write().unwrap(); - match handler.strip_personal_info(doc_id) { - Ok(_) => ToolOutcome::Ok { message: Some("Personal info stripped".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "get_metadata" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.get_metadata(doc_id) { - Ok(metadata) => ToolOutcome::Metadata { metadata: serde_json::to_value(metadata).unwrap() }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - - "save_document" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.save_document(doc_id, &PathBuf::from(output_path)) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Document saved to {}", output_path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "close_document" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - let mut handler = self.handler.write().unwrap(); - match handler.close_document(doc_id) { - Ok(_) => ToolOutcome::Ok { message: Some("Document closed successfully".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - - "list_documents" => { - let handler = self.handler.read().unwrap(); - let documents = handler.list_documents(); - ToolOutcome::Documents { documents: serde_json::to_value(documents).unwrap() } - }, - - "convert_to_pdf" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - let prefer_external = arguments.get("prefer_external").and_then(|v| v.as_bool()).unwrap_or(false); - - let handler = self.handler.read().unwrap(); - let metadata = match handler.get_metadata(doc_id) { - Ok(m) => m, - Err(e) => return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: e.to_string(), annotations: None })], is_error: Some(true), meta: None }, - }; - - match if prefer_external { self.converter.docx_to_pdf_with_preference(&metadata.path, &PathBuf::from(output_path), true) } else { self.converter.docx_to_pdf(&metadata.path, &PathBuf::from(output_path)) } { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Document converted to PDF at {}", output_path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "export_pdf_with_field_refresh" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - let prefer_external = arguments.get("prefer_external").and_then(|v| v.as_bool()).unwrap_or(true); - - // Embed fields first - { - let handler = self.handler.read().unwrap(); - if let Err(e) = handler.embed_page_number_fields(doc_id) { - return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: serde_json::json!({"success": false, "error": e.to_string()}).to_string(), annotations: None })], is_error: Some(true), meta: None }; - } - } - - let handler = self.handler.read().unwrap(); - let metadata = match handler.get_metadata(doc_id) { - Ok(m) => m, - Err(e) => return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: serde_json::json!({"success": false, "error": e.to_string()}).to_string(), annotations: None })], is_error: Some(true), meta: None }, - }; - - let result = if prefer_external { - self.converter.docx_to_pdf_with_preference(&metadata.path, &PathBuf::from(output_path), true) - } else { - self.converter.docx_to_pdf(&metadata.path, &PathBuf::from(output_path)) - }; - - match result { - Ok(_) => ToolOutcome::Ok { message: Some(format!("PDF exported with field refresh at {}", output_path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: Some("Install LibreOffice or unoconv for hi-fidelity refresh".to_string()) }, - } - }, - - "convert_to_images" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_dir = arguments["output_dir"].as_str().unwrap_or(""); - let format = arguments.get("format") - .and_then(|f| f.as_str()) - .unwrap_or("png"); - let dpi = arguments.get("dpi") - .and_then(|d| d.as_u64()) - .unwrap_or(150) as u32; - - let handler = self.handler.read().unwrap(); - let metadata = match handler.get_metadata(doc_id) { - Ok(m) => m, - Err(e) => return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: e.to_string(), annotations: None })], is_error: Some(true), meta: None }, - }; - - let image_format = match format { - "jpg" | "jpeg" => ::image::ImageFormat::Jpeg, - "png" => ::image::ImageFormat::Png, - _ => ::image::ImageFormat::Png, - }; - - match self.converter.docx_to_images( - &metadata.path, - &PathBuf::from(output_dir), - image_format, - dpi - ) { - Ok(images) => ToolOutcome::Images { images: images.iter().map(|p| p.to_string_lossy().to_string()).collect(), message: Some(format!("Document converted to {} images", images.len())) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "convert_to_images_with_preference" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_dir = arguments["output_dir"].as_str().unwrap_or(""); - let format = arguments.get("format").and_then(|f| f.as_str()).unwrap_or("png"); - let dpi = arguments.get("dpi").and_then(|d| d.as_u64()).unwrap_or(150) as u32; - let prefer_external = arguments.get("prefer_external").and_then(|v| v.as_bool()).unwrap_or(true); - - let handler = self.handler.read().unwrap(); - let metadata = match handler.get_metadata(doc_id) { - Ok(m) => m, - Err(e) => return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "text".into(), text: e.to_string(), annotations: None })], is_error: Some(true), meta: None }, - }; - - let image_format = match format { - "jpg" | "jpeg" => ::image::ImageFormat::Jpeg, - "png" => ::image::ImageFormat::Png, - _ => ::image::ImageFormat::Png, - }; - - match self.converter.docx_to_images_with_preference( - &metadata.path, - &PathBuf::from(output_dir), - image_format, - dpi, - prefer_external, - ) { - Ok(images) => ToolOutcome::Images { images: images.iter().map(|p| p.to_string_lossy().to_string()).collect(), message: Some(format!("Document converted to {} images", images.len())) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: Some("Install LibreOffice/ImageMagick for hi-fidelity path".to_string()) }, - } - }, - - "get_document_structure" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.analyze_structure(doc_id) { - Ok(summary) => ToolOutcome::Metadata { metadata: summary }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } - } - }, - "get_outline" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.get_outline(doc_id) { - Ok(outline) => ToolOutcome::Metadata { metadata: outline }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "get_ranges" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let selector = arguments["selector"].as_str().unwrap_or(""); - let handler = self.handler.read().unwrap(); - match handler.get_ranges(doc_id, selector) { - Ok(ranges) => ToolOutcome::Metadata { metadata: serde_json::json!({"ranges": ranges}) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None }, - } - }, - "replace_range_text" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let range_id = arguments["range_id"].clone(); - let text = arguments["text"].as_str().unwrap_or(""); - let range: crate::docx_handler::RangeId = match serde_json::from_value(range_id) { - Ok(v) => v, - Err(e) => { - return CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "application/json".into(), text: serde_json::json!({"success": false, "code": ErrorCode::ValidationError, "error": format!("invalid range_id: {}", e)}).to_string(), annotations: None })], is_error: Some(true), meta: None }; - } - }; - let mut handler = self.handler.write().unwrap(); - match handler.replace_range_text(doc_id, &range, text) { - Ok(_) => ToolOutcome::Ok { message: Some("Range text replaced".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - "set_table_cell_text" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let ti = arguments["table_index"].as_u64().unwrap_or(0) as usize; - let r = arguments["row"].as_u64().unwrap_or(0) as usize; - let c = arguments["col"].as_u64().unwrap_or(0) as usize; - let text = arguments["text"].as_str().unwrap_or(""); - let mut handler = self.handler.write().unwrap(); - match handler.set_table_cell_text(doc_id, ti, r, c, text) { - Ok(_) => ToolOutcome::Ok { message: Some("Table cell updated".into()) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::ValidationError, error: e.to_string(), hint: None }, - } - }, - - "analyze_formatting" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - // For now, return basic analysis - in full implementation would parse DOCX XML - ToolOutcome::Metadata { metadata: serde_json::json!({ - "styles_used": ["Normal", "Heading1", "Heading2"], - "fonts_detected": ["Calibri", "Arial"], - "has_tables": true, - "has_images": false, - "has_hyperlinks": false, - "page_count": 1, - "section_count": 1 - }) } - }, - - "get_word_count" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.extract_text(doc_id) { - Ok(text) => { - let words: Vec<&str> = text.split_whitespace().collect(); - let characters = text.chars().count(); - let characters_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count(); - let paragraphs = text.lines().filter(|line| !line.trim().is_empty()).count(); - let sentences = text.matches('.').count() + text.matches('!').count() + text.matches('?').count(); - - ToolOutcome::Statistics { statistics: serde_json::json!({ - "words": words.len(), - "characters": characters, - "characters_no_spaces": characters_no_spaces, - "paragraphs": paragraphs, - "sentences": sentences, - "pages": ((words.len() as f32 / 250.0).ceil() as usize).max(1), - "reading_time_minutes": (words.len() as f32 / 200.0).ceil() as usize - }) } - } - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } - } - }, - - "search_text" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let search_term = arguments["search_term"].as_str().unwrap_or(""); - let case_sensitive = arguments.get("case_sensitive").and_then(|v| v.as_bool()).unwrap_or(false); - let _whole_word = arguments.get("whole_word").and_then(|v| v.as_bool()).unwrap_or(false); - - let handler = self.handler.read().unwrap(); - match handler.extract_text(doc_id) { - Ok(text) => { - let search_text = if case_sensitive { text.clone() } else { text.to_lowercase() }; - let search_for = if case_sensitive { search_term.to_string() } else { search_term.to_lowercase() }; - - let mut matches = Vec::new(); - let mut position = 0; - - while let Some(found_pos) = search_text[position..].find(&search_for) { - let absolute_pos = position + found_pos; - - // Extract context around the match - let context_start = absolute_pos.saturating_sub(50); - let context_end = (absolute_pos + search_for.len() + 50).min(text.len()); - let context = &text[context_start..context_end]; - - matches.push(json!({ - "position": absolute_pos, - "context": context, - "line": text[..absolute_pos].matches('\n').count() + 1 - })); - - position = absolute_pos + search_for.len(); - } - - ToolOutcome::Metadata { metadata: serde_json::json!({ - "matches": matches, - "total_matches": matches.len() - }) } - } - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } - } - }, - - "export_to_markdown" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.extract_text(doc_id) { - Ok(text) => { - // Simple conversion to Markdown - in full implementation would preserve formatting - let mut markdown = String::new(); - - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - markdown.push('\n'); - continue; - } - - // Detect and convert headings - if trimmed.len() < 100 && trimmed.chars().any(|c| c.is_uppercase()) { - if trimmed.chars().all(|c| c.is_uppercase() || c.is_whitespace()) { - markdown.push_str(&format!("# {}\n\n", trimmed)); - } else { - markdown.push_str(&format!("## {}\n\n", trimmed)); - } - } else { - markdown.push_str(&format!("{}\n\n", trimmed)); - } - } - - // Save to file - match std::fs::write(output_path, markdown) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Document exported to Markdown at {}", output_path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: format!("Failed to save file: {}", e), hint: None } - } - } - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } - } - }, - - "export_to_html" => { - let doc_id = arguments["document_id"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - - let handler = self.handler.read().unwrap(); - match handler.extract_text(doc_id) { - Ok(text) => { - // Simple conversion to HTML - preserve headings heuristically - let mut html = String::from("\n"); - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { continue; } - if trimmed.len() < 100 && trimmed.chars().any(|c| c.is_uppercase()) { - if trimmed.chars().all(|c| c.is_uppercase() || c.is_whitespace()) { - html.push_str(&format!("

{}

\n", html_escape::encode_text(trimmed))); - } else { - html.push_str(&format!("

{}

\n", html_escape::encode_text(trimmed))); - } - } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") { - // naive list handling: wrap each as
  • - html.push_str(&format!("
    • {}
    \n", html_escape::encode_text(&trimmed[2..]))); - } else { - html.push_str(&format!("

    {}

    \n", html_escape::encode_text(trimmed))); - } - } - html.push_str("\n"); - match std::fs::write(output_path, html) { - Ok(_) => ToolOutcome::Ok { message: Some(format!("Document exported to HTML at {}", output_path)) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: format!("Failed to save file: {}", e), hint: None } - } - } - Err(e) => ToolOutcome::Error { code: ErrorCode::DocNotFound, error: e.to_string(), hint: None } - } - }, - - "get_security_info" => { - ToolOutcome::Security { security: serde_json::json!({ - "readonly_mode": self.security_config.readonly_mode, - "sandbox_mode": self.security_config.sandbox_mode, - "allow_external_tools": self.security_config.allow_external_tools, - "allow_network": self.security_config.allow_network, - "max_document_size": self.security_config.max_document_size, - "max_open_documents": self.security_config.max_open_documents, - "summary": self.security_config.get_summary(), - "readonly_commands": crate::security::SecurityConfig::get_readonly_commands().len(), - "write_commands": crate::security::SecurityConfig::get_write_commands().len() - }) } - }, - - "get_storage_info" => { - let handler = self.handler.read().unwrap(); - match handler.get_storage_info() { - Ok(info) => ToolOutcome::Storage { storage: info.get("storage").cloned().unwrap_or(serde_json::json!({})) }, - Err(e) => ToolOutcome::Error { code: ErrorCode::InternalError, error: e.to_string(), hint: None }, - } - }, - - "list_templates" => { - let mut templates = Vec::new(); - if self.templates_dir.exists() { - if let Ok(entries) = std::fs::read_dir(&self.templates_dir) { - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_file() { - if let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext.eq_ignore_ascii_case("docx") { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - templates.push(name.to_string()); - } - } - } - } - } - } - } - templates.sort(); - ToolOutcome::Metadata { - metadata: serde_json::json!({ "templates": templates }), - } - }, - - "open_template" => { - let name = arguments["name"].as_str().unwrap_or(""); - if name.is_empty() { - ToolOutcome::Error { - code: ErrorCode::ValidationError, - error: "Template name is required".to_string(), - hint: Some("Provide 'name' with a .docx filename from list_templates".to_string()), - } - } else { - let path = self.templates_dir.join(name); - if !path.exists() || !path.is_file() { - ToolOutcome::Error { - code: ErrorCode::ValidationError, - error: format!("Template not found: {}", name), - hint: Some("Check list_templates for available names".to_string()), - } - } else { - let mut handler = self.handler.write().unwrap(); - match handler.open_document(&path) { - Ok(doc_id) => ToolOutcome::Created { - document_id: doc_id, - message: Some(format!("Opened template '{}' as document", name)), - }, - Err(e) => ToolOutcome::Error { - code: ErrorCode::InternalError, - error: e.to_string(), - hint: None, - }, - } - } - } - }, - - "generate_from_template" => { - let template_name = arguments["template_name"].as_str().unwrap_or(""); - let output_path = arguments["output_path"].as_str().unwrap_or(""); - let fields = arguments.get("fields").and_then(|v| v.as_object()).cloned().unwrap_or_default(); - - if template_name.is_empty() { - ToolOutcome::Error { - code: ErrorCode::ValidationError, - error: "template_name is required".to_string(), - hint: Some("Provide the template file name from list_templates".to_string()), - } - } else if output_path.is_empty() { - ToolOutcome::Error { - code: ErrorCode::ValidationError, - error: "output_path is required".to_string(), - hint: Some("Provide an absolute path where the generated DOCX will be saved".to_string()), - } - } else { - let template_path = self.templates_dir.join(template_name); - if !template_path.exists() || !template_path.is_file() { - ToolOutcome::Error { - code: ErrorCode::ValidationError, - error: format!("Template not found: {}", template_name), - hint: Some("Check list_templates for available names".to_string()), - } - } else { - let mut handler = self.handler.write().unwrap(); - - // Open template - let doc_id = match handler.open_document(&template_path) { - Ok(id) => id, - Err(e) => { - drop(handler); - return CallToolResponse { - content: vec![ToolResponseContent::Text(TextContent { - content_type: "application/json".into(), - text: serde_json::json!({ - "success": false, - "error": e.to_string(), - "code": ErrorCode::InternalError - }).to_string(), - annotations: None, - })], - is_error: Some(true), - meta: None, - }; - } - }; - - // Apply field replacements - let mut replace_count = 0usize; - for (key, value) in &fields { - let placeholder = format!("{{{{{}}}}}", key); - let val_str = match value { - Value::String(s) => s.clone(), - _ => value.to_string(), - }; - if let Ok(count) = handler.find_and_replace_advanced( - &doc_id, - &placeholder, - &val_str, - false, - true, - false, - ) { - replace_count += count; - } - } - - // Save generated document - let out_path = PathBuf::from(output_path); - let result = if let Some(parent) = out_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - ToolOutcome::Error { - code: ErrorCode::InternalError, - error: format!("Failed to create output directory: {}", e), - hint: None, - } - } else { - match handler.save_document(&doc_id, &out_path) { - Ok(()) => ToolOutcome::Ok { - message: Some(format!( - "Generated document from template '{}' with {} replacements at {}", - template_name, replace_count, output_path - )), - }, - Err(e) => ToolOutcome::Error { - code: ErrorCode::InternalError, - error: e.to_string(), - hint: None, - }, - } - } - } else { - match handler.save_document(&doc_id, &out_path) { - Ok(()) => ToolOutcome::Ok { - message: Some(format!( - "Generated document from template '{}' with {} replacements at {}", - template_name, replace_count, output_path - )), - }, - Err(e) => ToolOutcome::Error { - code: ErrorCode::InternalError, - error: e.to_string(), - hint: None, - }, - } - }; - - // Optionally close template document - let _ = handler.close_document(&doc_id); - drop(handler); - result - } - } - }, - - _ => { - ToolOutcome::Error { code: ErrorCode::UnknownTool, error: format!("Unknown or unsupported tool: {}", name), hint: None } - } - }; - // Backward-compatible JSON shaping with success boolean at top-level - let legacy = match outcome { - ToolOutcome::Ok { message } => { - let mut obj = serde_json::json!({"success": true}); - if let Some(m) = message { obj["message"] = serde_json::Value::String(m); } - obj - } - ToolOutcome::Created { document_id, message } => { - let mut obj = serde_json::json!({"success": true, "document_id": document_id}); - if let Some(m) = message { obj["message"] = serde_json::Value::String(m); } - obj - } - ToolOutcome::Text { text } => serde_json::json!({"success": true, "text": text}), - ToolOutcome::Metadata { metadata } => { - // Heuristic: if this looks like search results (matches/total_matches), flatten. - let is_search_shape = metadata.get("matches").is_some() || metadata.get("total_matches").is_some(); - if is_search_shape { - let mut obj = serde_json::json!({"success": true}); - if let Some(map) = metadata.as_object() { - for (k, v) in map { obj[&k[..]] = v.clone(); } - } - obj - } else { - serde_json::json!({"success": true, "metadata": metadata}) - } - } - ToolOutcome::Documents { documents } => serde_json::json!({"success": true, "documents": documents}), - ToolOutcome::Images { images, message } => { - let mut obj = serde_json::json!({"success": true, "images": images}); - if let Some(m) = message { obj["message"] = serde_json::Value::String(m); } - obj - } - ToolOutcome::Security { security } => serde_json::json!({"success": true, "security": security}), - ToolOutcome::Storage { storage } => serde_json::json!({"success": true, "storage": storage}), - ToolOutcome::Statistics { statistics } => serde_json::json!({"success": true, "statistics": statistics}), - ToolOutcome::Structure { structure } => serde_json::json!({"success": true, "structure": structure}), - ToolOutcome::Error { code, error, hint } => { - let mut obj = serde_json::json!({"success": false, "error": error}); - obj["code"] = serde_json::json!(code); - if let Some(h) = hint { obj["hint"] = serde_json::Value::String(h); } - obj - } - }; - CallToolResponse { content: vec![ToolResponseContent::Text(TextContent { content_type: "application/json".into(), text: legacy.to_string(), annotations: None })], is_error: None, meta: None } - } -} \ No newline at end of file diff --git a/src/fonts.rs b/src/fonts.rs deleted file mode 100644 index 289d120..0000000 --- a/src/fonts.rs +++ /dev/null @@ -1,50 +0,0 @@ -use once_cell::sync::Lazy; - -// Conditionally embed fonts if they exist -// If fonts don't exist, we'll use empty placeholders and rely on PDF built-in fonts - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub static LIBERATION_SANS_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Regular.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub static LIBERATION_SANS_REGULAR: &[u8] = &[]; - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub static LIBERATION_SANS_BOLD: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Bold.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub static LIBERATION_SANS_BOLD: &[u8] = &[]; - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub static LIBERATION_SANS_ITALIC: &[u8] = include_bytes!("../assets/fonts/LiberationSans-Italic.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub static LIBERATION_SANS_ITALIC: &[u8] = &[]; - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub static LIBERATION_MONO_REGULAR: &[u8] = include_bytes!("../assets/fonts/LiberationMono-Regular.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub static LIBERATION_MONO_REGULAR: &[u8] = &[]; - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub const EMBEDDED_FONT_REGULAR: &[u8] = include_bytes!("../assets/fonts/NotoSans-Regular.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub const EMBEDDED_FONT_REGULAR: &[u8] = &[]; - -#[cfg(all(feature = "embedded-fonts", not(debug_assertions)))] -pub const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../assets/fonts/NotoSans-Bold.ttf"); -#[cfg(not(all(feature = "embedded-fonts", not(debug_assertions))))] -pub const EMBEDDED_FONT_BOLD: &[u8] = &[]; - -pub struct EmbeddedFonts { - pub regular: &'static [u8], - pub bold: &'static [u8], - pub italic: &'static [u8], - pub mono: &'static [u8], -} - -pub static FONTS: Lazy = Lazy::new(|| { - EmbeddedFonts { - regular: LIBERATION_SANS_REGULAR, - bold: LIBERATION_SANS_BOLD, - italic: LIBERATION_SANS_ITALIC, - mono: LIBERATION_MONO_REGULAR, - } -}); \ No newline at end of file diff --git a/src/fonts_cli.rs b/src/fonts_cli.rs deleted file mode 100644 index 9ce22b4..0000000 --- a/src/fonts_cli.rs +++ /dev/null @@ -1,128 +0,0 @@ -use anyhow::{Context, Result}; -use sha2::{Digest, Sha256}; -use std::fs; -use std::io::Read; -use std::path::Path; - -const FONTS_DIR: &str = "assets/fonts"; - -// Pin sources and expected checksums -const LIBERATION_VERSION: &str = "2.1.5"; -const LIBERATION_TAR_URL: &str = "https://github.com/liberationfonts/liberation-fonts/files/7261482/liberation-fonts-ttf-2.1.5.tar.gz"; -const NOTO_BASE_URL: &str = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSans"; - -const FONT_FILES: &[(&str, Option<&str>)] = &[ - ("LiberationSans-Regular.ttf", Some("76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8")), - ("LiberationSans-Bold.ttf", Some("788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173")), - ("LiberationSans-Italic.ttf", Some("e5bae5c4cde31f22142753855f4f8fb86da6ff39955ed3c0a11248b0d16948b0")), - ("LiberationMono-Regular.ttf", Some("f2b83c763e8afd21709333370bed4774337fae82267937e2b5aea7e2fbd922c1")), - ("NotoSans-Regular.ttf", Some("b85c38ecea8a7cfb39c24e395a4007474fa5a4fc864f6ee33309eb4948d232d5")), - ("NotoSans-Bold.ttf", Some("c976e4b1b99edc88775377fcc21692ca4bfa46b6d6ca6522bfda505b28ff9d6a")), -]; - -pub fn download_fonts_blocking() -> Result<()> { - fs::create_dir_all(FONTS_DIR).context("create fonts dir")?; - - // Download Liberation tarball - let tar_bytes = download_bytes(LIBERATION_TAR_URL)?; - extract_liberation_from_tar(&tar_bytes, Path::new(FONTS_DIR))?; - - // Download Noto fonts - for name in ["NotoSans-Regular.ttf", "NotoSans-Bold.ttf"] { - let url = format!("{}/{}", NOTO_BASE_URL, name); - let bytes = download_bytes(&url)?; - let out = Path::new(FONTS_DIR).join(name); - fs::write(&out, bytes).context("write noto font")?; - // verify immediate - verify_single(&out, expected_for(name))?; - } - // Verify all fonts after extraction - verify_fonts_blocking() -} - -pub fn verify_fonts_blocking() -> Result<()> { - for (name, expected_opt) in FONT_FILES { - let path = Path::new(FONTS_DIR).join(name); - if !path.exists() { - anyhow::bail!("missing font: {}", name); - } - let actual = sha256_file(&path)?; - if let Some(expected) = expected_opt { - if !actual.eq_ignore_ascii_case(expected) { - anyhow::bail!("checksum mismatch for {}: {} != {}", name, actual, expected); - } - } - } - Ok(()) -} - -fn download_bytes(url: &str) -> Result> { - let res = ureq::get(url).call().context("request failed")?; - let mut buf = Vec::new(); - res.into_reader().read_to_end(&mut buf).context("read body")?; - Ok(buf) -} - -fn extract_liberation_from_tar(tar_gz: &[u8], out_dir: &Path) -> Result<()> { - let gz = flate2::read::GzDecoder::new(tar_gz); - let mut archive = tar::Archive::new(gz); - - for entry in archive.entries().context("iter entries")? { - let mut entry = entry.context("entry")?; - // Extract filename into an owned String to avoid borrowing `entry` - let filename_owned: Option = { - let path_buf = entry.path().context("entry path")?; - path_buf - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - }; - let Some(filename) = filename_owned.as_deref() else { continue }; - match filename { - "LiberationSans-Regular.ttf" | - "LiberationSans-Bold.ttf" | - "LiberationSans-Italic.ttf" | - "LiberationMono-Regular.ttf" => { - let dest = out_dir.join(filename); - let context_msg = format!("unpack {}", filename); - entry.unpack(&dest).context(context_msg)?; - // verify immediate - verify_single(&dest, expected_for(filename))?; - } - _ => {} - } - } - - Ok(()) -} - -fn expected_for(name: &str) -> Option<&'static str> { - FONT_FILES.iter().find(|(n, _)| *n == name).and_then(|(_, s)| *s) -} - -fn verify_single(path: &Path, expected: Option<&str>) -> Result<()> { - if let Some(exp) = expected { - let actual = sha256_file(path)?; - if !actual.eq_ignore_ascii_case(exp) { - anyhow::bail!( - "checksum mismatch for {}: {} != {}", - path.display(), - actual, - exp - ); - } - } - Ok(()) -} - -fn sha256_file(path: &Path) -> Result { - let mut file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?; - let mut hasher = Sha256::new(); - let mut buf = [0u8; 8192]; - loop { - let n = file.read(&mut buf)?; - if n == 0 { break; } - hasher.update(&buf[..n]); - } - Ok(format!("{:x}", hasher.finalize())) -} diff --git a/src/http_server.rs b/src/http_server.rs deleted file mode 100644 index 06355d7..0000000 --- a/src/http_server.rs +++ /dev/null @@ -1,208 +0,0 @@ -use axum::{ - extract::{ - ws::{Message}, - State, WebSocketUpgrade, - }, - response::Html, - routing::{get, post}, - Router, - Json, -}; -use futures::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use std::{ - net::SocketAddr, - str::FromStr, - sync::Arc, -}; -use tower_http::cors::{Any, CorsLayer}; -use tracing::info; - -use crate::docx_tools::DocxToolsProvider; - -/// Application state shared across HTTP handlers -pub struct AppState { - pub provider: DocxToolsProvider, -} - -/// Request to call a tool -#[derive(Debug, Deserialize)] -pub struct ToolCallRequest { - pub name: String, - pub arguments: serde_json::Value, -} - -/// Response from a tool call -#[derive(Debug, Serialize)] -pub struct ToolCallResponse { - pub success: bool, - pub content: serde_json::Value, - pub error: Option, -} - -/// Response with list of tools -#[derive(Debug, Serialize)] -pub struct ListToolsResponse { - pub success: bool, - pub tools: Vec, -} - -/// Start the HTTP server -pub async fn start_http_server(addr: &str, provider: DocxToolsProvider) -> anyhow::Result<()> { - let state = Arc::new(AppState { provider }); - - let app = Router::new() - .with_state(state.clone()) - // Serve HTML interface - .route("/", get(index_handler)) - .route("/api/tools", get(list_tools_handler)) - .route("/api/call", post(call_tool_handler)) - .route("/ws", get(ws_handler)) - // CORS policy - allow all origins on LAN - .layer( - CorsLayer::new() - .allow_origin(Any) - .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) - ); - - let addr = SocketAddr::from_str(addr).unwrap_or_else(|_| { - info!("Invalid address format, using default 0.0.0.0:3000"); - "0.0.0.0:3000".parse().unwrap() - }); - - info!("Starting HTTP server on {}", addr); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app).await?; - - Ok(()) -} - -/// Serve the HTML interface -async fn index_handler() -> Html { - Html(include_str!("../assets/html_interface.html").to_string()) -} - -/// List available tools -async fn list_tools_handler(State(state): State>) -> Json { - let tools = state.provider.list_tools().await; - - let tool_list: Vec = tools.iter().map(|t| { - serde_json::json!({ - "name": t.name, - "description": t.description, - "input_schema": t.input_schema - }) - }).collect(); - - Json(ListToolsResponse { - success: true, - tools: tool_list, - }) -} - -/// Call a tool via HTTP POST -async fn call_tool_handler( - State(state): State>, - Json(request): Json, -) -> Json { - let response = state.provider.call_tool(&request.name, request.arguments).await; - - // Convert response to JSON - let content = if let Some(content) = response.content.first() { - match content { - mcp_core::types::ToolResponseContent::Text(text) => { - serde_json::from_str(&text.text).unwrap_or_else(|_| { - serde_json::json!({"text": text.text.clone()}) - }) - }, - mcp_core::types::ToolResponseContent::Image(image) => { - serde_json::json!({ - "data": image.data, - "mimeType": image.mime_type - }) - }, - _ => serde_json::json!({}), - } - } else { - serde_json::json!({}) - }; - - Json(ToolCallResponse { - success: response.is_error.unwrap_or(false) == false, - content, - error: response.is_error.unwrap_or(false).then(|| "Tool call failed".to_string()), - }) -} - -/// WebSocket handler for real-time communication -async fn ws_handler( - ws: WebSocketUpgrade, - State(state): State> -) -> axum::response::Response { - ws.on_upgrade(move |socket| async move { - let provider = state.provider.clone(); - let mut ws = socket; - - // Handle WebSocket messages - while let Some(msg) = ws.recv().await { - let msg = match msg { - Ok(msg) => msg, - Err(_) => continue, - }; - - let text = match msg { - Message::Text(text) => text.to_string(), - _ => continue, - }; - - // Parse request - let request: ToolCallRequest = match serde_json::from_str(&text) { - Ok(req) => req, - Err(e) => { - let error_response = ToolCallResponse { - success: false, - content: serde_json::json!({}), - error: Some(format!("Parse error: {}", e)), - }; - let _ = ws.send(Message::Text( - serde_json::to_string(&error_response).unwrap_or("{}".to_string()) - )).await; - continue; - } - }; - - // Call tool - let response = provider.call_tool(&request.name, request.arguments).await; - - // Convert response to JSON - let content = if let Some(content) = response.content.first() { - match content { - mcp_core::types::ToolResponseContent::Text(text) => { - serde_json::from_str(&text.text).unwrap_or_else(|_| { - serde_json::json!({"text": text.text.clone()}) - }) - }, - mcp_core::types::ToolResponseContent::Image(image) => { - serde_json::json!({ - "data": image.data, - "mimeType": image.mime_type - }) - }, - } - } else { - serde_json::json!({}) - }; - - let ws_response = ToolCallResponse { - success: response.is_error.unwrap_or(false) == false, - content, - error: response.is_error.unwrap_or(false).then(|| "Tool call failed".to_string()), - }; - - let _ = ws.send(Message::Text( - serde_json::to_string(&ws_response).unwrap_or("{}".to_string()) - )).await; - } - }) -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index b284b43..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod security; -pub mod fonts_cli; -pub mod response; - -// Expose primary modules for tests and external use -pub mod docx_tools; -pub mod docx_handler; -pub mod pure_converter; -pub mod converter; -#[cfg(feature = "advanced-docx")] -pub mod advanced_docx; - -pub use security::{Args, SecurityConfig, SecurityMiddleware, SecurityError}; diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index e795137..0000000 --- a/src/main.rs +++ /dev/null @@ -1,154 +0,0 @@ -use anyhow::Result; -#[cfg(feature = "runtime-server")] -use mcp_server::Server; -use tracing::info; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; -use clap::Parser; - -#[cfg(feature = "runtime-server")] -mod docx_tools; -#[cfg(feature = "runtime-server")] -mod docx_handler; -#[cfg(feature = "runtime-server")] -mod converter; -#[cfg(feature = "runtime-server")] -mod pure_converter; -#[cfg(all(feature = "runtime-server", feature = "advanced-docx"))] -mod advanced_docx; -#[cfg(feature = "http-server")] -mod http_server; -mod security; - -#[cfg(feature = "embedded-fonts")] -mod fonts; - -#[cfg(feature = "runtime-server")] -use docx_tools::DocxToolsProvider; - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::registry() - .with(fmt::layer()) - .with(EnvFilter::from_default_env()) - .init(); - - // Parse command line arguments (which also includes environment variables) - let args = security::Args::parse(); - - // Handle top-level subcommands that should run and exit - if let Some(cmd) = &args.command { - match cmd { - security::CliCommand::Fonts { action } => { - match action { - security::FontsAction::Download => { - docx_mcp::fonts_cli::download_fonts_blocking()?; - info!("Fonts downloaded successfully"); - return Ok(()); - } - security::FontsAction::Verify => { - docx_mcp::fonts_cli::verify_fonts_blocking()?; - info!("Fonts verified successfully"); - return Ok(()); - } - } - } - } - } - - // Check if HTTP mode is enabled before consuming args - let http_mode = args.http_mode; - let http_address = args.http_address.clone(); - let templates_dir = args.templates_dir.clone(); - - // Create the tools provider - let security_config = security::SecurityConfig::from_args(args); - info!("Starting DOCX MCP Server - Security: {}", security_config.get_summary()); - info!("Templates directory: {}", templates_dir); - - let provider = DocxToolsProvider::new_with_security_and_templates( - security_config, - std::path::PathBuf::from(&templates_dir), - ); - - // Check if HTTP mode is enabled - if http_mode { - #[cfg(feature = "http-server")] - { - let addr = http_address.unwrap_or_else(|| "0.0.0.0:3000".to_string()); - info!("Starting in HTTP mode on {}", addr); - return http_server::start_http_server(&addr, provider).await; - } - - #[cfg(not(feature = "http-server"))] - { - eprintln!("HTTP mode requires the 'http-server' feature to be enabled during build."); - eprintln!("Rebuild with: cargo build --release --features http-server"); - return Err(anyhow::anyhow!("HTTP mode not available")); - } - } - - // Default: stdio mode - #[cfg(feature = "runtime-server")] - { - use mcp_server::{Router, Server}; - use mcp_server::router::RouterService; - use mcp_server::router::CapabilitiesBuilder; - use mcp_spec::{prompt::Prompt, resource::Resource}; - use mcp_spec::protocol::ServerCapabilities; - use mcp_spec::content::Content; - use mcp_spec::tool::Tool as SpecTool; - use serde_json::Value as JsonValue; - use std::pin::Pin; - use std::future::Future; - use tokio::io::{stdin, stdout}; - - #[derive(Clone)] - struct DocxRouter(docx_tools::DocxToolsProvider); - - impl Router for DocxRouter { - fn name(&self) -> String { "docx-mcp-server".to_string() } - fn instructions(&self) -> String { "DOCX tools for reading and exporting".to_string() } - fn capabilities(&self) -> ServerCapabilities { - CapabilitiesBuilder::new().with_tools(true).build() - } - fn list_tools(&self) -> Vec { - let rt = tokio::runtime::Handle::current(); - let tools = rt.block_on(self.0.list_tools()); - tools.into_iter().map(|t| SpecTool{ name: t.name, description: t.description.unwrap_or_default(), input_schema: t.input_schema }).collect() - } - fn call_tool(&self, tool_name: &str, arguments: JsonValue) -> Pin, mcp_spec::handler::ToolError>> + Send + 'static>> { - let provider = self.0.clone(); - let name = tool_name.to_string(); - Box::pin(async move { - let resp = provider.call_tool(&name, arguments).await; - let text = match resp.content.get(0) { - Some(mcp_core::types::ToolResponseContent::Text(t)) => t.text.clone(), - _ => serde_json::to_string(&resp).unwrap_or_else(|_| "{}".to_string()), - }; - Ok(vec![Content::text(text)]) - }) - } - fn list_resources(&self) -> Vec { vec![] } - fn read_resource(&self, _uri: &str) -> Pin> + Send + 'static>> { - Box::pin(async { Ok(String::new()) }) - } - fn list_prompts(&self) -> Vec { vec![] } - fn get_prompt(&self, _prompt_name: &str) -> Pin> + Send + 'static>> { - Box::pin(async { Ok(String::new()) }) - } - } - - let router = DocxRouter(provider); - let service = RouterService(router); - let server = Server::new(service); - let transport = mcp_server::ByteTransport::new(stdin(), stdout()); - server.run(transport).await?; - } - - #[cfg(not(feature = "runtime-server"))] - { - eprintln!("Runtime server disabled. Rebuild with --features runtime-server to run the MCP server."); - } - - Ok(()) -} diff --git a/src/pure_converter.rs b/src/pure_converter.rs deleted file mode 100644 index e967f9a..0000000 --- a/src/pure_converter.rs +++ /dev/null @@ -1,476 +0,0 @@ -use anyhow::{Context, Result}; -use ::image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; -use printpdf::*; -use std::fs::{self, File}; -use std::io::{BufWriter, Read}; -use std::path::{Path, PathBuf}; -use tempfile::NamedTempFile; -use tracing::{info}; -use roxmltree; -use zip::ZipArchive; -use ::lopdf::{dictionary, Object}; - -pub struct PureRustConverter; - -impl PureRustConverter { - pub fn new() -> Self { - Self - } - - /// Extract text from DOCX using pure Rust XML parsing - pub fn extract_text_from_docx(&self, docx_path: &Path) -> Result { - 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" { - let mut buf = Vec::new(); - file.read_to_end(&mut buf)?; - document_xml = String::from_utf8_lossy(&buf).to_string(); - break; - } - } - - if document_xml.is_empty() { - anyhow::bail!("No document.xml found in DOCX file"); - } - - // Parse XML and extract text with basic whitespace semantics - let doc = roxmltree::Document::parse(&document_xml)?; - let mut text = String::new(); - let mut last_char: Option = None; - - for node in doc.descendants() { - let name = node.tag_name().name(); - match name { - // Paragraph boundary - "p" => { - if !text.ends_with('\n') { - text.push('\n'); - last_char = Some('\n'); - } - } - // Text run - "t" => { - if let Some(node_text) = node.text() { - // Preserve spaces if xml:space="preserve" - let preserve = node.attribute(("xml", "space")).map(|v| v == "preserve").unwrap_or(false); - let mut content = node_text.to_string(); - if !preserve { - // Collapse internal newlines and excessive spaces - content = content.replace('\n', " "); - } - if !content.is_empty() { - // Insert a space if needed between words - if let Some(c) = last_char { if !c.is_whitespace() && !content.starts_with([' ', '\n', '\t']) { text.push(' '); } } - text.push_str(&content); - last_char = content.chars().rev().next(); - } - } - } - // Line break - "br" => { - text.push('\n'); - last_char = Some('\n'); - } - // Tab - "tab" => { - text.push('\t'); - last_char = Some('\t'); - } - _ => {} - } - } - - 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(()) - } - - // Backward-compat wrapper names expected by tests - pub fn convert_docx_to_pdf(&self, docx_path: &Path, pdf_path: &Path) -> Result<()> { - self.docx_to_pdf_pure(docx_path, pdf_path) - } - - pub fn convert_docx_to_images(&self, docx_path: &Path, output_dir: &Path) -> Result> { - self.docx_to_images_pure(docx_path, output_dir, ImageFormat::Png) - } - - pub fn convert_docx_to_images_with_format(&self, docx_path: &Path, output_dir: &Path, format: &str, _dpi: u32) -> Result> { - let fmt = match format.to_lowercase().as_str() { - "jpg" | "jpeg" => ImageFormat::Jpeg, - _ => ImageFormat::Png, - }; - self.docx_to_images_pure(docx_path, output_dir, fmt) - } - - /// 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> { - // 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)); - // JPEG does not support RGBA; convert to RGB if needed - if let ImageFormat::Jpeg = format { - let rgb = img.to_rgb8(); - ::image::DynamicImage::ImageRgb8(rgb).save_with_format(&output_path, format)?; - } else { - 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 { - // 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> { - // 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}; - - // 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::>(), - "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> { - 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(()) - } -} \ No newline at end of file diff --git a/py-docx/src/py_docx/__init__.py b/src/py_docx/__init__.py similarity index 100% rename from py-docx/src/py_docx/__init__.py rename to src/py_docx/__init__.py diff --git a/py-docx/src/py_docx/docx_tools.py b/src/py_docx/docx_tools.py similarity index 100% rename from py-docx/src/py_docx/docx_tools.py rename to src/py_docx/docx_tools.py diff --git a/py-docx/src/py_docx/security.py b/src/py_docx/security.py similarity index 100% rename from py-docx/src/py_docx/security.py rename to src/py_docx/security.py diff --git a/py-docx/src/py_docx/server.py b/src/py_docx/server.py similarity index 100% rename from py-docx/src/py_docx/server.py rename to src/py_docx/server.py diff --git a/py-docx/src/py_docx/templates.py b/src/py_docx/templates.py similarity index 100% rename from py-docx/src/py_docx/templates.py rename to src/py_docx/templates.py diff --git a/src/response.rs b/src/response.rs deleted file mode 100644 index 2b4314b..0000000 --- a/src/response.rs +++ /dev/null @@ -1,51 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ErrorCode { - ValidationError, - DocNotFound, - InternalError, - UnknownTool, -} - -#[derive(Debug, Clone)] -pub enum ToolOutcome { - Ok { - message: Option, - }, - Created { - document_id: String, - message: Option, - }, - Text { - text: String, - }, - Metadata { - metadata: Value, - }, - Documents { - documents: Value, - }, - Images { - images: Vec, - message: Option, - }, - Security { - security: Value, - }, - Storage { - storage: Value, - }, - Statistics { - statistics: Value, - }, - Structure { - structure: Value, - }, - Error { - code: ErrorCode, - error: String, - hint: Option, - }, -} diff --git a/src/security.rs b/src/security.rs deleted file mode 100644 index 7a83a70..0000000 --- a/src/security.rs +++ /dev/null @@ -1,557 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; -use std::env; -use tracing::{debug, info}; -use clap::{Parser, Subcommand}; - -/// Command line arguments for the DOCX MCP server -#[derive(Parser, Debug)] -#[command(name = "docx-mcp")] -#[command(about = "A comprehensive Model Context Protocol (MCP) server for Microsoft Word DOCX file manipulation")] -#[command(version)] -pub struct Args { - /// Enable readonly mode - only allow viewing operations - #[arg(long, env = "DOCX_MCP_READONLY")] - pub readonly: bool, - - /// Comma-separated whitelist of allowed commands - #[arg(long, env = "DOCX_MCP_WHITELIST", value_delimiter = ',')] - pub whitelist: Option>, - - /// Comma-separated blacklist of forbidden commands - #[arg(long, env = "DOCX_MCP_BLACKLIST", value_delimiter = ',')] - pub blacklist: Option>, - - /// Enable sandbox mode - restrict file operations to temp directory only - #[arg(long, env = "DOCX_MCP_SANDBOX")] - pub sandbox: bool, - - /// Disable external tools (LibreOffice, etc.) - #[arg(long, env = "DOCX_MCP_NO_EXTERNAL_TOOLS")] - pub no_external_tools: bool, - - /// Disable network operations - #[arg(long, env = "DOCX_MCP_NO_NETWORK")] - pub no_network: bool, - - /// Maximum document size in bytes - #[arg(long, env = "DOCX_MCP_MAX_SIZE")] - pub max_size: Option, - - /// Maximum number of open documents - #[arg(long, env = "DOCX_MCP_MAX_DOCS")] - pub max_docs: Option, - - /// Enable HTTP server mode for HTML interface - #[arg(long, env = "DOCX_MCP_HTTP")] - pub http_mode: bool, - - /// HTTP server address and port (default: 0.0.0.0:3000) - #[arg(long, env = "DOCX_MCP_HTTP_ADDRESS")] - pub http_address: Option, - - /// Path to directory containing template .docx files - #[arg(long, env = "DOCX_MCP_TEMPLATES_DIR", default_value = "/templates")] - pub templates_dir: String, - - /// Optional top-level subcommand (e.g., fonts download) - #[command(subcommand)] - pub command: Option, -} - -/// 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>, - - /// Blacklist of forbidden commands (if set, these commands are blocked) - pub command_blacklist: Option>, - - /// Maximum document size in bytes (default: 100MB) - pub max_document_size: usize, - - /// Maximum number of open documents (default: 50) - pub max_open_documents: usize, - - /// Allow external tool usage (LibreOffice, etc.) - pub allow_external_tools: bool, - - /// Allow network operations (downloading templates, fonts, etc.) - pub allow_network: bool, - - /// Sandbox mode - restricts file operations to temp directory only - pub sandbox_mode: bool, -} - -/// Top-level CLI subcommands -#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)] -pub enum CliCommand { - /// Font utilities - Fonts { - #[command(subcommand)] - action: FontsAction, - }, -} - -/// Font-related actions -#[derive(Subcommand, Debug, Clone, Serialize, Deserialize)] -pub enum FontsAction { - /// Download open-source fonts into assets/fonts - Download, - /// Verify checksums of fonts in assets/fonts - Verify, -} - -impl Default for SecurityConfig { - fn default() -> Self { - Self { - readonly_mode: false, - command_whitelist: None, - command_blacklist: None, - max_document_size: 100 * 1024 * 1024, // 100MB - max_open_documents: 50, - allow_external_tools: true, - allow_network: true, - sandbox_mode: false, - } - } -} - -impl SecurityConfig { - /// Create configuration from command line arguments - pub fn from_args(args: Args) -> Self { - let mut config = Self::default(); - - // Apply command line arguments - if args.readonly { - config.readonly_mode = true; - info!("Running in READONLY mode - only viewing operations allowed"); - } - - if let Some(whitelist) = args.whitelist { - let commands: HashSet = whitelist.into_iter().collect(); - info!("Command whitelist enabled with {} commands", commands.len()); - config.command_whitelist = Some(commands); - } - - if let Some(blacklist) = args.blacklist { - let commands: HashSet = blacklist.into_iter().collect(); - info!("Command blacklist enabled with {} blocked commands", commands.len()); - config.command_blacklist = Some(commands); - } - - if args.sandbox { - config.sandbox_mode = true; - config.allow_external_tools = false; - config.allow_network = false; - info!("Running in SANDBOX mode - restricted file operations"); - } - - if args.no_external_tools { - config.allow_external_tools = false; - info!("External tools disabled"); - } - - if args.no_network { - config.allow_network = false; - info!("Network operations disabled"); - } - - if let Some(size) = args.max_size { - config.max_document_size = size; - info!("Max document size set to {} bytes", size); - } - - if let Some(max) = args.max_docs { - config.max_open_documents = max; - info!("Max open documents set to {}", max); - } - - config - } - - /// Load configuration from environment variables (deprecated, use from_args instead) - pub fn from_env() -> Self { - let mut config = Self::default(); - - // Check for readonly mode - if env::var("DOCX_MCP_READONLY").unwrap_or_default() == "true" { - config.readonly_mode = true; - info!("Running in READONLY mode - only viewing operations allowed"); - } - - // Check for command whitelist - if let Ok(whitelist) = env::var("DOCX_MCP_WHITELIST") { - let commands: HashSet = 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 = 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::() { - 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::() { - 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); - // Whitelist takes precedence over blacklist. - if let Some(ref whitelist) = self.command_whitelist { - if whitelist.contains(command) { - return true; - } else { - debug!("Command '{}' blocked: not in whitelist", command); - return false; - } - } - - // If no whitelist, enforce blacklist if present - 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.insert("get_security_info"); - commands.insert("get_storage_info"); - - 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(); - // Fast-path for non-existent paths under common temp prefixes - if !path.exists() { - if let Some(s) = path.to_str() { - if s.starts_with("/tmp/") || s.starts_with("/private/tmp/") { - return true; - } - } - } - // Avoid requiring the file to exist. Use parent directory for canonicalization when needed. - let candidate = if path.exists() { path.to_path_buf() } else { path.parent().unwrap_or(path).to_path_buf() }; - if let Ok(canonical_path) = candidate.canonicalize() { - if let Ok(canonical_temp) = temp_dir.canonicalize() { - if canonical_path.starts_with(&canonical_temp) { - return true; - } - // macOS sometimes resolves to /private/var; normalize for comparison - let cp = canonical_path.to_string_lossy(); - let ct = canonical_temp.to_string_lossy(); - let cp_norm = cp.replace("/private", ""); - let ct_norm = ct.replace("/private", ""); - if cp_norm.starts_with(&ct_norm) { - return true; - } - // Heuristic for macOS TMP subfolders (…/T/…) - if cp_norm.contains("/T/") { - return true; - } - // Heuristic for Linux /tmp - if cp_norm.starts_with("/tmp/") { - return true; - } - } - } - - false - } - - /// Get a summary of current security settings - pub fn get_summary(&self) -> String { - let mut summary: Vec = Vec::new(); - - if self.readonly_mode { - summary.push("📖 READONLY MODE".to_string()); - } - - if self.sandbox_mode { - summary.push("🔒 SANDBOX MODE".to_string()); - } - - if let Some(ref whitelist) = self.command_whitelist { - summary.push(format!("✅ Whitelist: {} commands", whitelist.len())); - } - - if let Some(ref blacklist) = self.command_blacklist { - summary.push(format!("🚫 Blacklist: {} commands", blacklist.len())); - } - - if !self.allow_external_tools { - summary.push("🔧 No external tools".to_string()); - } - - if !self.allow_network { - summary.push("🌐 No network access".to_string()); - } - - if summary.is_empty() { - "Standard mode (all features enabled)".to_string() - } else { - summary.join(" | ") - } - } -} - -/// Security middleware to check commands before execution -pub struct SecurityMiddleware { - config: SecurityConfig, -} - -impl SecurityMiddleware { - pub fn new(config: SecurityConfig) -> Self { - Self { config } - } - - /// Check if a command should be allowed to execute - pub fn check_command(&self, command: &str, arguments: &serde_json::Value) -> Result<(), SecurityError> { - // Check if command is allowed - if !self.config.is_command_allowed(command) { - return Err(SecurityError::CommandNotAllowed(command.to_string())); - } - - // Check file paths in arguments if in sandbox mode - if self.config.sandbox_mode { - self.check_paths_in_arguments(arguments)?; - } - - // Check document size limits for open/create operations - if command == "open_document" { - if let Some(path) = arguments.get("path").and_then(|v| v.as_str()) { - self.check_file_size(path)?; - } - } - - Ok(()) - } - - fn check_paths_in_arguments(&self, arguments: &serde_json::Value) -> Result<(), SecurityError> { - // Recursively check all string values that look like paths - match arguments { - serde_json::Value::String(s) => { - if s.contains('/') || s.contains('\\') { - let path = std::path::Path::new(s); - if !self.config.is_path_allowed(path) { - return Err(SecurityError::PathNotAllowed(s.to_string())); - } - } - } - serde_json::Value::Object(map) => { - for value in map.values() { - self.check_paths_in_arguments(value)?; - } - } - serde_json::Value::Array(arr) => { - for value in arr { - self.check_paths_in_arguments(value)?; - } - } - _ => {} - } - Ok(()) - } - - fn check_file_size(&self, path: &str) -> Result<(), SecurityError> { - let file_path = std::path::Path::new(path); - if let Ok(metadata) = std::fs::metadata(file_path) { - if metadata.len() as usize > self.config.max_document_size { - return Err(SecurityError::FileTooLarge { - size: metadata.len() as usize, - max_size: self.config.max_document_size, - }); - } - } - Ok(()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SecurityError { - #[error("Command not allowed: {0}")] - CommandNotAllowed(String), - - #[error("Path not allowed in sandbox mode: {0}")] - PathNotAllowed(String), - - #[error("File too large: {size} bytes (max: {max_size} bytes)")] - FileTooLarge { size: usize, max_size: usize }, - - #[error("Maximum number of open documents exceeded")] - TooManyDocuments, - - #[error("Operation requires external tools which are disabled")] - ExternalToolsDisabled, - - #[error("Operation requires network access which is disabled")] - NetworkDisabled, -} \ No newline at end of file diff --git a/tests/args_tests.rs b/tests/args_tests.rs deleted file mode 100644 index 17d5061..0000000 --- a/tests/args_tests.rs +++ /dev/null @@ -1,100 +0,0 @@ -use docx_mcp::security::{Args, SecurityConfig}; -use clap::Parser; -use std::env; - -fn reset_env() { - for (k, _) in env::vars() { - if k.starts_with("DOCX_MCP_") { - env::remove_var(k); - } - } -} - -#[test] -fn parses_flags_and_lists() { - reset_env(); - - let argv = [ - "docx-mcp", - "--readonly", - "--sandbox", - "--no-external-tools", - "--no-network", - "--whitelist", - "open_document,extract_text,get_metadata", - "--blacklist", - "save_document,add_paragraph", - "--max-size", - "1048576", - "--max-docs", - "10", - ]; - - let args = Args::parse_from(&argv); - assert!(args.readonly); - assert!(args.sandbox); - assert!(args.no_external_tools); - assert!(args.no_network); - assert_eq!(args.max_size, Some(1_048_576)); - assert_eq!(args.max_docs, Some(10)); - - let wl = args.whitelist.clone().unwrap(); - assert_eq!(wl, vec![ - "open_document".to_string(), - "extract_text".to_string(), - "get_metadata".to_string(), - ]); - - let bl = args.blacklist.clone().unwrap(); - assert_eq!(bl, vec![ - "save_document".to_string(), - "add_paragraph".to_string(), - ]); - - let cfg = SecurityConfig::from_args(args); - assert!(cfg.readonly_mode); - assert!(cfg.sandbox_mode); - assert!(!cfg.allow_external_tools); - assert!(!cfg.allow_network); - assert_eq!(cfg.max_document_size, 1_048_576); - assert_eq!(cfg.max_open_documents, 10); - - let wlset = cfg.command_whitelist.unwrap(); - assert!(wlset.contains("open_document")); - assert!(wlset.contains("extract_text")); - assert!(wlset.contains("get_metadata")); - - let blset = cfg.command_blacklist.unwrap(); - assert!(blset.contains("save_document")); - assert!(blset.contains("add_paragraph")); -} - -#[test] -fn parses_from_environment() { - reset_env(); - - env::set_var("DOCX_MCP_READONLY", "true"); - env::set_var("DOCX_MCP_SANDBOX", "true"); - env::set_var("DOCX_MCP_NO_EXTERNAL_TOOLS", "true"); - env::set_var("DOCX_MCP_NO_NETWORK", "true"); - env::set_var("DOCX_MCP_WHITELIST", "open_document,extract_text"); - env::set_var("DOCX_MCP_BLACKLIST", "save_document"); - env::set_var("DOCX_MCP_MAX_SIZE", "2048"); - env::set_var("DOCX_MCP_MAX_DOCS", "7"); - - let cfg = SecurityConfig::from_env(); - - assert!(cfg.readonly_mode); - assert!(cfg.sandbox_mode); - assert!(!cfg.allow_external_tools); - assert!(!cfg.allow_network); - assert_eq!(cfg.max_document_size, 2048); - assert_eq!(cfg.max_open_documents, 7); - - let wl = cfg.command_whitelist.unwrap(); - assert!(wl.contains("open_document")); - assert!(wl.contains("extract_text")); - - let bl = cfg.command_blacklist.unwrap(); - assert!(bl.contains("save_document")); -} diff --git a/tests/converter_tests.rs b/tests/converter_tests.rs deleted file mode 100644 index 75006c1..0000000 --- a/tests/converter_tests.rs +++ /dev/null @@ -1,500 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use docx_mcp::pure_converter::PureRustConverter; -use tempfile::TempDir; -use std::path::{Path, PathBuf}; -use std::fs; -use pretty_assertions::assert_eq; -use rstest::*; - -fn setup_test_handler_with_content() -> (DocxHandler, String, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let mut handler = DocxHandler::new().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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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().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().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 > 500); // 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().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 > 1000); // 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().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().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().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().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()?; - 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(()) -} \ No newline at end of file diff --git a/tests/docx_handler_tests.rs b/tests/docx_handler_tests.rs deleted file mode 100644 index 5cc8cdd..0000000 --- a/tests/docx_handler_tests.rs +++ /dev/null @@ -1,317 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use tempfile::TempDir; -use std::path::PathBuf; -use pretty_assertions::assert_eq; -use rstest::*; -use chrono::Utc; - -fn setup_test_handler() -> (DocxHandler, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let handler = DocxHandler::new().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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - - 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 (lower threshold due to simplified text extraction) - let words: Vec<&str> = text.split_whitespace().collect(); - assert!(words.len() > 300); -} - -#[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("🚀📝✨")); -} \ No newline at end of file diff --git a/tests/e2e_workflow_tests.rs b/tests/e2e_workflow_tests.rs deleted file mode 100644 index e70e996..0000000 --- a/tests/e2e_workflow_tests.rs +++ /dev/null @@ -1,933 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_tools::DocxToolsProvider; -use docx_mcp::security::SecurityConfig; -use mcp_core::types::ToolResponseContent; -use serde_json::{json, Value}; -use tempfile::TempDir; -use std::collections::HashSet; -use std::fs; -use std::path::PathBuf; -use pretty_assertions::assert_eq; -// tokio_test not needed in async tests here - -enum ToolResult { - Success(Value), - Error(String), -} - -async fn tool_result(provider: &DocxToolsProvider, name: &str, args: Value) -> ToolResult { - let resp = provider.call_tool(name, args).await; - let val = match resp.content.get(0) { - Some(ToolResponseContent::Text(t)) => serde_json::from_str::(&t.text) - .unwrap_or_else(|_| json!({"success": false, "error": t.text.clone()})), - _ => json!({"success": false, "error": "non-text response"}), - }; - if val.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { - ToolResult::Success(val) - } else { - let err = val.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string(); - ToolResult::Error(err) - } -} - -/// Test complete document creation workflow from start to finish -#[tokio::test] -async fn test_complete_document_workflow() -> Result<()> { - let temp_dir = TempDir::new().unwrap(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - - // Step 1: Create a new document - let create_result = tool_result(&provider, "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 = tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Annual Report 2024", - "level": 1 - })).await; - assert!(matches!(title_result, ToolResult::Success(_)), "add_heading failed at start"); - - // Step 3: Add introduction - let intro_result = tool_result(&provider, "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 = tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Executive Summary", - "level": 2 - })).await; - assert!(matches!(exec_heading_result, ToolResult::Success(_))); - - let exec_content = tool_result(&provider, "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 = tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Financial Highlights", - "level": 2 - })).await; - assert!(matches!(financial_heading, ToolResult::Success(_))); - - let table_result = tool_result(&provider, "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 = tool_result(&provider, "add_page_break", json!({ - "document_id": doc_id - })).await; - assert!(matches!(page_break_result, ToolResult::Success(_))); - - let strategy_heading = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "set_header", json!({ - "document_id": doc_id, - "text": "Annual Report 2024 | Confidential" - })).await; - assert!(matches!(header_result, ToolResult::Success(_))); - - let footer_result = tool_result(&provider, "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 = tool_result(&provider, "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() > 600, "Document should have substantial content"); - }, - ToolResult::Error(e) => panic!("Failed to extract text: {}", e), - } - - // Step 10: Get document metadata - let metadata_result = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - - // Create initial document - let create_result = tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Project Status Report", - "level": 1 - })).await; - - tool_result(&provider, "add_paragraph", json!({ - "document_id": doc_id, - "text": "Current project status and upcoming milestones." - })).await; - - // Add tasks list - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Current Tasks", - "level": 2 - })).await; - - tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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) - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Completed Items", - "level": 2 - })).await; - - tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Identified Risks", - "level": 2 - })).await; - - tool_result(&provider, "add_paragraph", json!({ - "document_id": doc_id, - "text": "The following risks have been identified and mitigation strategies are in place:", - "style": { - "italic": true - } - })).await; - - tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - 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 = tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": format!("{}'s Weekly Report", member), - "level": 1 - })).await; - - tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "create_document", json!({})).await; - let summary_id = match summary_result { - ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(), - ToolResult::Error(e) => panic!("Failed to create summary document: {}", e), - }; - - // Add summary header - tool_result(&provider, "add_heading", json!({ - "document_id": summary_id, - "text": "Team Weekly Summary Report", - "level": 1 - })).await; - - tool_result(&provider, "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 { - tool_result(&provider, "add_heading", json!({ - "document_id": summary_id, - "text": format!("{} Highlights", member), - "level": 2 - })).await; - - // Extract text from member's document - let extract_result = tool_result(&provider, "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) - }; - - tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": summary_id, - "text": "Team Totals", - "level": 2 - })).await; - - tool_result(&provider, "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())); - tool_result(&provider, "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"); - tool_result(&provider, "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(); - // 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), - command_blacklist: None, - max_document_size: 1024 * 1024, // 1MB - max_open_documents: 5, - allow_external_tools: false, - allow_network: false, - }; - - let provider = DocxToolsProvider::with_base_dir_and_security(temp_dir.path(), security_config); - - // Test security info - let security_info = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&unrestricted_provider, "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 - tool_result(&unrestricted_provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Security Test Document", - "level": 1 - })).await; - - tool_result(&unrestricted_provider, "add_paragraph", json!({ - "document_id": doc_id, - "text": "This document is used to test readonly access capabilities in a security-restricted environment." - })).await; - - tool_result(&unrestricted_provider, "add_list", json!({ - "document_id": doc_id, - "items": [ - "Test text extraction", - "Test search functionality", - "Test metadata retrieval", - "Test word counting" - ], - "ordered": true - })).await; - // Save document to a sandbox-allowed path and reopen it under restricted provider - // Use OS temp dir root to satisfy sandbox canonicalization - let saved_path = std::env::temp_dir().join("docx-mcp").join("restricted_source.docx"); - std::fs::create_dir_all(saved_path.parent().unwrap()).unwrap(); - tool_result(&unrestricted_provider, "save_document", json!({ - "document_id": doc_id, - "output_path": saved_path.to_str().unwrap() - })).await; - // Open under restricted provider to import into its registry - let opened = tool_result(&provider, "open_document", json!({ - "path": saved_path.to_str().unwrap() - })).await; - let doc_id = match opened { - ToolResult::Success(value) => value["document_id"].as_str().unwrap().to_string(), - ToolResult::Error(e) => panic!("Restricted provider failed to open saved document: {}", e), - }; - - // Now test readonly operations with restricted provider - // These should work because they're in the whitelist - - // Test text extraction - let extract_result = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - - // 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 = tool_result(&provider, operation, args).await; - match result { - ToolResult::Success(value) => { - assert!(!value.get("success").unwrap_or(&json!(true)).as_bool().unwrap()); - println!("{} correctly handled invalid document ID (structured)", operation); - }, - ToolResult::Error(e) => { - // Any error is acceptable for invalid IDs across operations - 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 = tool_result(&provider, 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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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(()) -} \ No newline at end of file diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs deleted file mode 100644 index bf4584c..0000000 --- a/tests/fixtures/mod.rs +++ /dev/null @@ -1,457 +0,0 @@ -//! Test fixtures and helper data for the docx-mcp test suite - -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use serde_json::{json, Value}; -use std::collections::HashMap; -use tempfile::TempDir; - -pub mod sample_documents; -pub mod test_data; - -/// Common test fixture for creating a handler with a temporary directory -pub fn create_test_handler() -> (DocxHandler, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let handler = DocxHandler::new().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 = (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 = (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 { - vec![ - "First bullet point".to_string(), - "Second bullet point".to_string(), - "Third bullet point".to_string(), - ] - } - - pub fn numbered_steps() -> Vec { - 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 { - 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 { - 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 { - (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) -> 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 { - 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 { - 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"})), - ] - } -} \ No newline at end of file diff --git a/tests/fixtures/sample_documents.rs b/tests/fixtures/sample_documents.rs deleted file mode 100644 index c854097..0000000 --- a/tests/fixtures/sample_documents.rs +++ /dev/null @@ -1,530 +0,0 @@ -//! Sample document templates and content for testing - -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use super::{TestStyles, TestTables, TestLists, TestContent}; - -/// Creates a business letter document for testing -pub fn create_business_letter(handler: &mut DocxHandler) -> Result { - 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 { - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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 { - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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 { - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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 { - 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 { - 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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - handler.add_table(&doc_id, formatted_table)?; - - Ok(doc_id) -} \ No newline at end of file diff --git a/tests/fixtures/test_data.rs b/tests/fixtures/test_data.rs deleted file mode 100644 index 28da757..0000000 --- a/tests/fixtures/test_data.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Test data generators and utilities - -use serde_json::{json, Value}; -use std::collections::HashMap; - -/// Generates test data for various document types and scenarios -pub struct TestDataGenerator; - -impl TestDataGenerator { - /// Generate test paragraphs with varying complexity - pub fn generate_paragraphs(count: usize, complexity: ParagraphComplexity) -> Vec { - 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> { - let mut table_data = Vec::new(); - - // Generate header row - let headers: Vec = (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 = (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 { - 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 { - 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 { - 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, - pub departments: Vec, - pub positions: Vec, - pub locations: Vec, -} - -/// 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, -} - -/// 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], 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() - } -} \ No newline at end of file diff --git a/tests/golden_more_xml_tests.rs b/tests/golden_more_xml_tests.rs deleted file mode 100644 index 882ab12..0000000 --- a/tests/golden_more_xml_tests.rs +++ /dev/null @@ -1,237 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, TableData, TableMerge}; -use tempfile::TempDir; -use std::fs; -use zip::ZipArchive; -use docx_mcp::docx_handler::MarginsSpec; - -fn open_zip_str(path: &std::path::Path, name: &str) -> Result { - let file = fs::File::open(path)?; - let mut zip = ZipArchive::new(file)?; - let mut f = zip.by_name(name)?; - let mut s = String::new(); - use std::io::Read as _; - f.read_to_string(&mut s)?; - Ok(s) -} - -#[test] -fn test_embed_page_number_fields_into_header_xml() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - // Add header with placeholder - handler.set_page_numbering(&doc_id, "header", Some("Page {PAGE} of {PAGES}"))?; - - // Save once to ensure header part exists - let out_path = temp_dir.path().join("page_fields.docx"); - handler.save_document(&doc_id, &out_path)?; - - // Embed field codes and resave to propagate to out_path - handler.embed_page_number_fields(&doc_id)?; - handler.save_document(&doc_id, &out_path)?; - - // Verify header XML has field runs - let header_xml = open_zip_str(&out_path, "word/header1.xml")?; - assert!(header_xml.contains("w:fldChar") && header_xml.contains("PAGE") && header_xml.contains("NUMPAGES"), - "Expected PAGE/NUMPAGES fields in header1.xml, got: {}", header_xml); - Ok(()) -} - -#[test] -fn test_section_break_emits_page_break() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - handler.add_paragraph(&doc_id, "Before section", None)?; - handler.add_section_break(&doc_id, Some("A4"), Some("portrait"), None)?; - handler.add_paragraph(&doc_id, "After section", None)?; - - let out_path = temp_dir.path().join("section_break.docx"); - handler.save_document(&doc_id, &out_path)?; - - // Best-effort placeholder: expect a page break in document.xml - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(doc_xml.contains("w:br") && doc_xml.contains("w:type=\"page\""), - "Expected a page break to denote section break"); - Ok(()) -} - -#[test] -fn test_table_merge_best_effort_xml() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - // 2x2 table where first row cells are merged (2 columns) - let table = TableData { - rows: vec![ - vec!["TopLeft".into(), "RightMergedShouldBeEmpty".into()], - vec!["BottomLeft".into(), "BottomRight".into()], - ], - headers: None, - border_style: Some("single".into()), - col_widths: None, - merges: Some(vec![TableMerge { row: 0, col: 0, row_span: 1, col_span: 2 }]), - cell_shading: None, - }; - - handler.add_table(&doc_id, table)?; - let out_path = temp_dir.path().join("table_merge.docx"); - handler.save_document(&doc_id, &out_path)?; - - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - // Expect TopLeft to be present once, and RightMergedShouldBeEmpty to be absent - assert!(doc_xml.contains("TopLeft")); - assert!(!doc_xml.contains("RightMergedShouldBeEmpty")); - - // When hi-fidelity-tables is enabled, verify gridSpan - #[cfg(feature = "hi-fidelity-tables")] - { - assert!(doc_xml.contains("w:gridSpan"), "Expected w:gridSpan for horizontal merge"); - // For row_span in this test it's 1, so no vMerge expected - assert!(!doc_xml.contains("w:vMerge w:val=\"restart\"")); - } - Ok(()) -} - -#[test] -fn test_table_vmerge_and_col_widths_injection() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - // 3x2 table with a vertical merge on first column (2 rows) and column widths - let table = TableData { - rows: vec![ - vec!["A".into(), "B".into()], - vec!["A2-should-be-empty".into(), "C".into()], - vec!["D".into(), "E".into()], - ], - headers: None, - border_style: None, - col_widths: Some(vec![2400, 3600]), - merges: Some(vec![TableMerge { row: 0, col: 0, row_span: 2, col_span: 1 }]), - cell_shading: None, - }; - - handler.add_table(&doc_id, table)?; - let out_path = temp_dir.path().join("table_vmerge.docx"); - handler.save_document(&doc_id, &out_path)?; - - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(!doc_xml.contains("A2-should-be-empty")); - - #[cfg(feature = "hi-fidelity-tables")] - { - // Expect vMerge restart and continue - assert!(doc_xml.contains("")); - assert!(doc_xml.contains("")); - - // Expect tblGrid with specified widths - assert!(doc_xml.contains("")); - assert!(doc_xml.contains("") && doc_xml.contains("")); - } - - Ok(()) -} - -#[test] -fn test_footer_field_embedding() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - handler.set_page_numbering(&doc_id, "footer", Some("Page {PAGE} of {PAGES}"))?; - let out_path = temp_dir.path().join("footer_fields.docx"); - handler.save_document(&doc_id, &out_path)?; - handler.embed_page_number_fields(&doc_id)?; - handler.save_document(&doc_id, &out_path)?; - let footer_xml = open_zip_str(&out_path, "word/footer1.xml")?; - assert!(footer_xml.contains("w:fldChar") && footer_xml.contains("NUMPAGES")); - Ok(()) -} - -#[test] -fn test_styles_and_lists_and_sections_hifi_xml() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - // Table with header row to trigger TableHeader style usage - let table = TableData { - rows: vec![ - vec!["H1".into(), "H2".into()], - vec!["x".into(), "y".into()], - ], - headers: Some(vec!["H1".into(), "H2".into()]), - border_style: None, - col_widths: Some(vec![3000, 3000]), - merges: None, - cell_shading: None, - }; - handler.add_table(&doc_id, table)?; - - // Ordered and unordered lists - handler.add_list(&doc_id, vec!["one".into(), "two".into()], true)?; - handler.add_list(&doc_id, vec!["dot".into(), "dash".into()], false)?; - - // Section setup - handler.add_section_break(&doc_id, Some("Letter"), Some("landscape"), Some(MarginsSpec { top: Some(1.25), bottom: Some(1.25), left: Some(1.0), right: Some(1.0) }))?; - - let out_path = temp_dir.path().join("hifi_bundle.docx"); - handler.save_document(&doc_id, &out_path)?; - - #[cfg(feature = "hi-fidelity-styles")] - { - let styles_xml = open_zip_str(&out_path, "word/styles.xml")?; - assert!(styles_xml.contains("w:styleId=\"TableHeader\""), "Expected TableHeader style defined"); - } - #[cfg(feature = "hi-fidelity-lists")] - { - let numbering_xml = open_zip_str(&out_path, "word/numbering.xml")?; - assert!(numbering_xml.contains("w:abstractNumId=\"10\"")); - assert!(numbering_xml.contains("w:abstractNumId=\"20\"")); - } - #[cfg(feature = "hi-fidelity-sections")] - { - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(doc_xml.contains("w:sectPr")); - assert!(doc_xml.contains("w:orient=\"landscape\"")); - assert!(doc_xml.contains("w:pgMar")); - } - - Ok(()) -} - -#[test] -fn test_insert_toc_and_bookmark_placeholders() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - handler.add_heading(&doc_id, "Intro", 1)?; - handler.insert_bookmark_after_heading(&doc_id, "Intro", "bm-intro")?; - handler.insert_toc(&doc_id, 1, 3, true)?; - - let out_path = temp_dir.path().join("toc_bm.docx"); - handler.save_document(&doc_id, &out_path)?; - - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(doc_xml.contains("__TOC__") || cfg!(feature = "hi-fidelity-toc"), "Expect TOC placeholder or transformed field"); - - #[cfg(feature = "hi-fidelity-toc")] - { - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(doc_xml.contains("w:fldChar") && doc_xml.contains("TOC")); - } - - #[cfg(feature = "hi-fidelity-bookmarks")] - { - let doc_xml = open_zip_str(&out_path, "word/document.xml")?; - assert!(!doc_xml.contains("__BOOKMARK__")); - } - - Ok(()) -} diff --git a/tests/golden_xml_tests.rs b/tests/golden_xml_tests.rs deleted file mode 100644 index 7136eab..0000000 --- a/tests/golden_xml_tests.rs +++ /dev/null @@ -1,72 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, ImageData}; -use tempfile::TempDir; -use std::fs; -use std::path::PathBuf; -use zip::ZipArchive; - -#[test] -fn test_golden_xml_links_images_numbering_header() -> Result<()> { - let temp_dir = TempDir::new()?; - let mut handler = DocxHandler::new_with_base_dir(temp_dir.path())?; - let doc_id = handler.create_document()?; - - // Content: paragraph, hyperlink, image, list with levels, header page numbering - handler.add_paragraph(&doc_id, "Intro paragraph.", None)?; - handler.add_hyperlink(&doc_id, "OpenAI", "https://openai.com")?; - - let png_data: Vec = { - // Small 1x1 PNG - let mut img = ::image::RgbaImage::new(1, 1); - img.put_pixel(0, 0, ::image::Rgba([0, 0, 0, 0])); - let r#dyn = ::image::DynamicImage::ImageRgba8(img); - let mut buf = Vec::new(); - r#dyn.write_to(&mut std::io::Cursor::new(&mut buf), ::image::ImageFormat::Png)?; - buf - }; - handler.add_image(&doc_id, ImageData { data: png_data, width: Some(10), height: Some(10), alt_text: Some("dot".into()) })?; - - handler.add_list(&doc_id, vec!["Item 1".into(), "Item 2".into()], true)?; - handler.add_list_item(&doc_id, "Sub 2.1", 1, true)?; - - handler.set_page_numbering(&doc_id, "header", Some("Page {PAGE} of {PAGES}"))?; - - // Save DOCX to disk - let out_path = temp_dir.path().join("golden_test.docx"); - handler.save_document(&doc_id, &out_path)?; - - // Open as zip and inspect XMLs - let file = fs::File::open(&out_path)?; - let mut zip = ZipArchive::new(file)?; - - // document.xml should contain hyperlink and drawing (image) and numPr (list numbering) - { - let mut doc_xml = zip.by_name("word/document.xml")?; - let mut s = String::new(); - use std::io::Read as _; - doc_xml.read_to_string(&mut s)?; - assert!(s.contains("w:hyperlink") || s.contains(":hyperlink"), "document.xml missing hyperlink element"); - assert!(s.contains("w:drawing") || s.contains(":drawing"), "document.xml missing drawing element for image"); - assert!(s.contains("w:numPr") || s.contains(":numPr"), "document.xml missing numbering properties for list"); - } - - // numbering.xml should exist - { - let mut numbering = zip.by_name("word/numbering.xml")?; - let mut s = String::new(); - use std::io::Read as _; - numbering.read_to_string(&mut s)?; - assert!(s.contains("w:numbering") || s.contains(":numbering"), "numbering.xml missing numbering root"); - } - - // header1.xml should contain our page numbering text template - { - let mut header = zip.by_name("word/header1.xml")?; - let mut s = String::new(); - use std::io::Read as _; - header.read_to_string(&mut s)?; - assert!(s.contains("Page {PAGE} of {PAGES}"), "header1.xml missing page numbering text"); - } - - Ok(()) -} diff --git a/tests/mcp_integration_tests.rs b/tests/mcp_integration_tests.rs deleted file mode 100644 index 0bd9f80..0000000 --- a/tests/mcp_integration_tests.rs +++ /dev/null @@ -1,639 +0,0 @@ -use docx_mcp::docx_tools::DocxToolsProvider; -use docx_mcp::security::SecurityConfig; -use mcp_core::types::ToolResponseContent; -use serde_json::{json, Value}; -use tempfile::TempDir; -use pretty_assertions::assert_eq; -use rstest::*; -enum ToolResult { - Success(Value), - Error(String), -} - -async fn tool_result(provider: &DocxToolsProvider, name: &str, args: serde_json::Value) -> ToolResult { - let resp = provider.call_tool(name, args).await; - let val = match resp.content.get(0) { - Some(ToolResponseContent::Text(t)) => serde_json::from_str::(&t.text) - .unwrap_or_else(|_| json!({"success": false, "error": t.text.clone()})), - _ => json!({"success": false, "error": "non-text response"}), - }; - if val.get("success").and_then(|v| v.as_bool()).unwrap_or(false) { - ToolResult::Success(val) - } else { - ToolResult::Error(val.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error").to_string()) - } -} - -async fn create_test_provider() -> (DocxToolsProvider, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - (provider, temp_dir) -} - -async fn create_test_provider_with_security(config: SecurityConfig) -> (DocxToolsProvider, TempDir) { - let temp_dir = TempDir::new().unwrap(); - let provider = DocxToolsProvider::with_base_dir_and_security(temp_dir.path(), 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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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." - }); - tool_result(&provider, "add_paragraph", add_args).await; - - // Search for text - let search_args = json!({ - "document_id": doc_id, - "search_term": "test", - "case_sensitive": false - }); - - let result = tool_result(&provider, "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 = tool_result(&provider, "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 - }); - tool_result(&provider, "add_paragraph", add_args).await; - - let args = json!({"document_id": doc_id}); - let result = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "nonexistent_tool", json!({})).await; - - match result { - ToolResult::Success(value) => { - assert!(!value["success"].as_bool().unwrap()); - let err = value["error"].as_str().unwrap(); - assert!(err.contains("Unknown or unsupported tool") || err.contains("Unknown tool")); - } - ToolResult::Error(e) => { - assert!(e.contains("Unknown or unsupported tool") || 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 = tool_result(&provider, "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) - }); - tool_result(&provider, "add_paragraph", args).await; - - doc_ids.push(doc_id); - } - - // List documents - let list_result = tool_result(&provider, "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 = tool_result(&provider, "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 = tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Test Document", - "level": 1 - })).await; - - tool_result(&provider, "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 = tool_result(&provider, "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), - } -} - -#[tokio::test] -async fn test_export_to_html() { - let (provider, temp_dir) = create_test_provider().await; - - let create_result = tool_result(&provider, "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 - tool_result(&provider, "add_heading", json!({ - "document_id": doc_id, - "text": "Test Document", - "level": 1 - })).await; - tool_result(&provider, "add_paragraph", json!({ - "document_id": doc_id, - "text": "This is a test paragraph." - })).await; - - // Export to HTML - let output_path = temp_dir.path().join("test_export.html"); - let args = json!({ - "document_id": doc_id, - "output_path": output_path.to_str().unwrap() - }); - let result = tool_result(&provider, "export_to_html", args).await; - match result { - ToolResult::Success(value) => { - assert!(value["success"].as_bool().unwrap()); - assert!(output_path.exists()); - let html = std::fs::read_to_string(&output_path).unwrap(); - assert!(html.contains("

    ") || html.contains("

    ") || html.contains("

    ")); - } - ToolResult::Error(e) => panic!("Expected success, got error: {}", e), - } -} - -#[tokio::test] -async fn test_get_storage_info_tool() { - let (provider, _temp_dir) = create_test_provider().await; - // Create a couple of docs to ensure some files exist - for _ in 0..2 { - let _ = tool_result(&provider, "create_document", json!({})).await; - } - let result = tool_result(&provider, "get_storage_info", json!({})).await; - match result { - ToolResult::Success(value) => { - assert!(value["success"].as_bool().unwrap()); - let storage = &value["storage"]; - assert!(storage["file_count"].is_number()); - assert!(storage["total_bytes"].is_number()); - } - ToolResult::Error(e) => panic!("get_storage_info failed: {}", e), - } -} - -#[tokio::test] -async fn test_list_tools_includes_new_exports() { - let (provider, _temp_dir) = create_test_provider().await; - let tools = provider.list_tools().await; - let names: Vec<_> = tools.iter().map(|t| t.name.clone()).collect(); - assert!(names.contains(&"export_to_markdown".to_string())); - assert!(names.contains(&"export_to_html".to_string())); -} - -// 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 = tool_result(&provider, 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 = tool_result(&provider, "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 - } - } -} \ No newline at end of file diff --git a/tests/performance_tests.rs b/tests/performance_tests.rs deleted file mode 100644 index 8f16aa6..0000000 --- a/tests/performance_tests.rs +++ /dev/null @@ -1,582 +0,0 @@ -use anyhow::Result; -use docx_mcp::docx_handler::{DocxHandler, DocxStyle, TableData}; -use docx_mcp::pure_converter::PureRustConverter; -use docx_mcp::docx_tools::DocxToolsProvider; -use docx_mcp::security::SecurityConfig; -use mcp_core::types::{CallToolResponse, ToolResponseContent}; -use serde_json::{json, Value}; -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_base_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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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_base_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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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::() 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_base_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()), - col_widths: None, - merges: None, - cell_shading: None, - }; - 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(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - let mut operation_times = Vec::new(); - - // Test document creation performance - let start = Instant::now(); - let create_resp: CallToolResponse = tokio_test::block_on(async { - provider.call_tool("create_document", json!({})).await - }); - let create_result = match create_resp.content.get(0) { - Some(ToolResponseContent::Text(t)) => serde_json::from_str::(&t.text) - .map_err(|e| e.to_string()), - _ => Err("non-text response".to_string()) - }; - let creation_time = start.elapsed(); - operation_times.push(("create_document", creation_time)); - - let doc_id = match create_result { - Ok(value) if value.get("success").and_then(|v| v.as_bool()).unwrap_or(false) => 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: CallToolResponse = tokio_test::block_on(async { - provider.call_tool("add_paragraph", args).await - }); - if let Some(ToolResponseContent::Text(t)) = result.content.get(0) { - let v: Value = serde_json::from_str(&t.text).unwrap_or(json!({"success": false})); - assert!(v.get("success").and_then(|b| b.as_bool()).unwrap_or(false), "Failed to add paragraph {}: {}", i, t.text); - } else { - panic!("Non-text response for add_paragraph"); - } - } - 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_resp: CallToolResponse = 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_resp.content.get(0) { - Some(ToolResponseContent::Text(t)) => { - let value: Value = serde_json::from_str(&t.text).unwrap(); - let text = value["text"].as_str().unwrap(); - println!("Extracted text length: {} characters", text.len()); - assert!(text.len() > 5000, "Should extract substantial text"); - }, - _ => panic!("Text extraction failed"), - } - - // 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(); - - // Test with default (permissive) security - let default_provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - - // 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::with_base_dir_and_security(temp_dir.path(), 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 reasonable but may vary on CI; allow up to 15x for very fast baselines - let overhead_ratio = restrictive_time.as_nanos() as f64 / default_time.as_nanos() as f64; - assert!(overhead_ratio < 15.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_base_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(); - let provider = DocxToolsProvider::with_base_dir(temp_dir.path()); - 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 - // Ensure we got a response shape; don't match legacy types here - } - - Ok(()) -} - -#[test] -fn test_resource_cleanup_performance() -> Result<()> { - let temp_dir = TempDir::new().unwrap(); - let mut handler = DocxHandler::new_with_base_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(()) -} \ No newline at end of file diff --git a/tests/security_tests.rs b/tests/security_tests.rs deleted file mode 100644 index 660f8ff..0000000 --- a/tests/security_tests.rs +++ /dev/null @@ -1,353 +0,0 @@ -use docx_mcp::security::{SecurityConfig, SecurityMiddleware, SecurityError}; -use serde_json::json; -use std::collections::HashSet; -use pretty_assertions::assert_eq; -use rstest::*; - -#[test] -fn test_default_security_config() { - let config = SecurityConfig::default(); - - assert!(!config.readonly_mode); - assert!(config.command_whitelist.is_none()); - assert!(config.command_blacklist.is_none()); - assert_eq!(config.max_document_size, 100 * 1024 * 1024); - assert_eq!(config.max_open_documents, 50); - assert!(config.allow_external_tools); - assert!(config.allow_network); - assert!(!config.sandbox_mode); -} - -#[test] -fn test_readonly_mode_allows_only_safe_commands() { - let config = SecurityConfig { - readonly_mode: true, - ..Default::default() - }; - - // Should allow readonly commands - assert!(config.is_command_allowed("open_document")); - assert!(config.is_command_allowed("extract_text")); - assert!(config.is_command_allowed("get_metadata")); - assert!(config.is_command_allowed("search_text")); - assert!(config.is_command_allowed("export_to_markdown")); - - // Should block write commands - assert!(!config.is_command_allowed("create_document")); - assert!(!config.is_command_allowed("add_paragraph")); - assert!(!config.is_command_allowed("save_document")); - assert!(!config.is_command_allowed("find_and_replace")); - assert!(!config.is_command_allowed("convert_to_pdf")); -} - -#[test] -fn test_command_whitelist() { - let mut whitelist = HashSet::new(); - whitelist.insert("open_document".to_string()); - whitelist.insert("extract_text".to_string()); - - let config = SecurityConfig { - command_whitelist: Some(whitelist), - command_blacklist: None, - ..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_whitelist: None, - 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), - command_blacklist: None, - 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, - command_blacklist: None, - ..Default::default() - } -} - -#[fixture] -fn sandbox_config() -> SecurityConfig { - SecurityConfig { - sandbox_mode: true, - allow_external_tools: false, - allow_network: false, - command_blacklist: None, - ..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), - command_blacklist: None, - 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); -} \ No newline at end of file