diff --git a/.github/CODECOV_SETUP.md b/.github/CODECOV_SETUP.md
new file mode 100644
index 0000000..d0a8d98
--- /dev/null
+++ b/.github/CODECOV_SETUP.md
@@ -0,0 +1,68 @@
+# Codecov Setup Guide
+
+To enable code coverage reporting with Codecov, follow these steps:
+
+## 1. Sign up for Codecov
+
+1. Go to [codecov.io](https://codecov.io)
+2. Sign in with your GitHub account
+3. Authorize Codecov to access your repositories
+
+## 2. Add Repository
+
+1. Find `Kydoimos97/FileCombiner` in your repository list
+2. Click "Setup repo" or enable it
+
+## 3. Get Upload Token
+
+1. Go to repository settings in Codecov
+2. Copy the upload token (it will look like: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`)
+
+## 4. Add Token to GitHub Secrets
+
+1. Go to your GitHub repository: `https://github.com/Kydoimos97/FileCombiner`
+2. Click on **Settings** β **Secrets and variables** β **Actions**
+3. Click **New repository secret**
+4. Name: `CODECOV_TOKEN`
+5. Value: Paste the token from Codecov
+6. Click **Add secret**
+
+## 5. Verify Setup
+
+Once the token is added:
+1. Push a commit or create a PR
+2. The CI workflow will run automatically
+3. Coverage reports will be uploaded to Codecov
+4. The coverage badge in README.md will update
+
+## Optional: Configure Codecov
+
+Create a `codecov.yml` file in the repository root to customize coverage settings:
+
+```yaml
+coverage:
+ status:
+ project:
+ default:
+ target: 80%
+ threshold: 1%
+ patch:
+ default:
+ target: 80%
+
+comment:
+ layout: "reach,diff,flags,tree"
+ behavior: default
+ require_changes: false
+```
+
+## Troubleshooting
+
+- **Badge shows "unknown"**: Token not set or first workflow hasn't completed yet
+- **Coverage not uploading**: Check GitHub Actions logs for errors
+- **Token invalid**: Regenerate token in Codecov and update GitHub secret
+
+## Resources
+
+- [Codecov Documentation](https://docs.codecov.com/)
+- [GitHub Actions Integration](https://docs.codecov.com/docs/github-actions-integration)
diff --git a/.github/CODECOV_STATUS.md b/.github/CODECOV_STATUS.md
new file mode 100644
index 0000000..e6dea60
--- /dev/null
+++ b/.github/CODECOV_STATUS.md
@@ -0,0 +1,79 @@
+# Codecov Integration Status
+
+## β
Completed Setup
+
+1. **GitHub Actions CI Workflow** (`.github/workflows/ci.yml`)
+ - Runs tests on Ubuntu, Windows, and macOS
+ - Collects code coverage using XPlat Code Coverage (coverlet)
+ - Uploads coverage to Codecov using v5 action
+ - Builds Windows x64 artifacts on main branch
+
+2. **Codecov Configuration** (`codecov.yml`)
+ - Target coverage: 70%
+ - Ignores test files and build artifacts
+ - Configured for PR comments with coverage diff
+
+3. **README Badges**
+ - CI status badge
+ - Codecov coverage badge
+ - License and .NET version badges
+
+4. **Test Project Configuration**
+ - `coverlet.collector` package installed (v6.0.2)
+ - Configured to generate Cobertura XML format
+
+## π§ Required: User Action
+
+**You need to add the CODECOV_TOKEN to GitHub Secrets:**
+
+1. Go to [Codecov.io](https://codecov.io) and sign in with GitHub
+2. Find your repository: `Kydoimos97/FileCombiner`
+3. Copy the upload token from repository settings
+4. Go to GitHub: `https://github.com/Kydoimos97/FileCombiner/settings/secrets/actions`
+5. Click "New repository secret"
+6. Name: `CODECOV_TOKEN`
+7. Value: Paste your token
+8. Save
+
+## π Current Status
+
+- Latest commit: `96ebbc0` - "fix: update codecov action to v5 and simplify coverage upload"
+- Branch: `feature/cli-fixes-and-simplification`
+- Status: **Pushed and ready for CI run**
+
+## π What Happens Next
+
+Once you add the `CODECOV_TOKEN`:
+
+1. The CI workflow will run on the next push/PR
+2. Tests will execute on all three platforms
+3. Coverage will be collected from Ubuntu runner
+4. Coverage report will upload to Codecov
+5. Badges in README will update with real data
+
+## π Troubleshooting
+
+If coverage upload fails:
+
+1. **Check token is set**: Verify `CODECOV_TOKEN` exists in GitHub Secrets
+2. **Check workflow logs**: Look for "Upload coverage reports to Codecov" step
+3. **Verify coverage files**: The workflow expects `./coverage/**/coverage.cobertura.xml`
+4. **Check Codecov dashboard**: Visit codecov.io to see upload status
+
+## π Files Modified
+
+- `.github/workflows/ci.yml` - CI workflow with Codecov integration
+- `codecov.yml` - Codecov configuration
+- `README.md` - Added badges
+- `.github/CODECOV_SETUP.md` - Setup instructions (reference)
+
+## β¨ Next Steps
+
+1. Add `CODECOV_TOKEN` to GitHub Secrets (required)
+2. Merge PR #1 to main branch
+3. Verify CI passes and coverage uploads
+4. Check coverage badge updates in README
+
+---
+
+**Note**: The workflow uses `fail_ci_if_error: false` so CI won't fail if Codecov upload has issues. This is intentional during initial setup. You can change it to `true` once everything is working.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..45aff64
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,82 @@
+name: CI
+
+on:
+ push:
+ branches: [ main, develop, feature/* ]
+ pull_request:
+ branches: [ main, develop ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Test on ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore dependencies
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --configuration Release --no-restore
+
+ - name: Run tests with coverage
+ run: dotnet test FileCombiner.Tests/FileCombiner.Tests.csproj /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura /p:CoverletOutput=../coverage.cobertura.xml --configuration Release --verbosity quiet
+
+ - name: List coverage files (debug)
+ if: matrix.os == 'ubuntu-latest'
+ run: |
+ echo "Searching for coverage files..."
+ ls -la *.xml || echo "No XML files found"
+ echo "=== Coverage file content (first 50 lines) ==="
+ head -50 coverage.cobertura.xml || echo "Cannot read coverage.cobertura.xml"
+ echo "=== Coverage file stats ==="
+ grep -E 'line-rate|branch-rate|lines-covered|lines-valid' coverage.cobertura.xml | head -5 || echo "Cannot parse coverage stats"
+
+ - name: Upload coverage reports to Codecov
+ if: matrix.os == 'ubuntu-latest'
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: ./coverage.cobertura.xml
+ flags: unittests
+ fail_ci_if_error: false
+ verbose: true
+
+ build:
+ name: Build Release Artifacts
+ runs-on: windows-latest
+ needs: test
+ if: github.ref == 'refs/heads/main'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Publish Windows x64
+ run: dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -p:DebugSymbols=false -o ./artifacts/win-x64
+
+ - name: Upload Windows artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: filecombiner-win-x64
+ path: ./artifacts/win-x64/filecombiner.exe
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index a353b45..d01552f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,77 @@
/bin/
/obj/
/*.DotSettings.user
+
+# Kiro IDE files
+.kiro/
+
+# Coverage reports
+coverage*.xml
+coveragereport/
+TestResults/
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Visual Studio cache/options
+.vs/
+.vscode/
+
+# ReSharper
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..5480842
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "kiroAgent.configureMCP": "Disabled"
+}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..605991f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,80 @@
+# Changelog
+
+All notable changes to FileCombiner will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Added
+- `--interactive` / `-i` flag for opt-in interactive mode
+- Comprehensive property-based testing with FsCheck
+- Improved help text using CommandLine library's built-in help generation
+- Path resolution tests to ensure location independence
+
+### Changed
+- **BREAKING**: Interactive mode is now opt-in via `--interactive` flag instead of automatic fallback
+- **BREAKING**: Directory argument is now optional (defaults to current directory)
+- Simplified interactive mode from 7 questions to 4 essential questions
+- Help text now uses CommandLine library's automatic generation
+- Fixed Spectre.Console markup escaping in help display
+- Fixed service initialization hang caused by circular dependency
+- Improved async/await patterns throughout codebase
+
+### Removed
+- **BREAKING**: The following options are now hidden (still work for backward compatibility):
+ - `--include` - Use `--extensions` instead
+ - `--ignore-dirs` - Use `--exclude` with directory patterns instead
+ - `--compact` - Feature removed (rarely used)
+ - `--no-tree` - Tree generation simplified
+ - `--max-files` - Now uses sensible default of 1000
+ - `--max-file-size` - Now uses sensible default of 10MB
+
+### Fixed
+- Spectre.Console markup parsing errors in help display
+- Application hanging after service provider initialization
+- Circular dependency between ContentExtractorFactory and FileContentExtractor
+
+## Migration Guide
+
+### For users of removed options:
+
+**`--include` patterns**
+- **Before**: `filecombiner . --include "*.cs,*.js"`
+- **After**: `filecombiner . --extensions .cs,.js`
+
+**`--ignore-dirs` patterns**
+- **Before**: `filecombiner . --ignore-dirs "bin,obj"`
+- **After**: `filecombiner . --exclude "**/bin/**,**/obj/**"`
+
+**`--compact` mode**
+- This feature has been removed. The default output format is now optimized.
+
+**`--no-tree` flag**
+- Tree generation is now always included and optimized.
+
+**`--max-files` and `--max-file-size`**
+- These now use sensible defaults (1000 files, 10MB per file).
+- If you need different limits, please open an issue.
+
+### For users relying on automatic interactive mode:
+
+**Before**: Running `filecombiner` without arguments would enter interactive mode.
+
+**After**: Running `filecombiner` without arguments processes the current directory with defaults.
+
+To enter interactive mode, use the `--interactive` or `-i` flag:
+```bash
+filecombiner --interactive
+```
+
+### Default behavior changes:
+
+- **No arguments**: Now processes current directory instead of entering interactive mode
+- **Directory only**: `filecombiner ./src` now works without additional prompts
+- **Sensible defaults**: Auto-detects text files, max depth of 5, outputs to clipboard
+
+## [Previous Versions]
+
+_(No previous versions documented)_
diff --git a/CLI/CommandLineOptions.cs b/CLI/CommandLineOptions.cs
deleted file mode 100644
index e9a5b97..0000000
--- a/CLI/CommandLineOptions.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using CommandLine;
-using JetBrains.Annotations;
-
-namespace FileCombiner.CLI;
-
-///
-/// Command line options using CommandLineParser library (no defaults here).
-/// Defaults live in AppConfig.CreateDefault (SSOT).
-///
-[Verb("combine", isDefault: true, HelpText = "Combine files from a directory into a single reference document")]
-[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
-public class CommandLineOptions
-{
- [Value(0, Required = true, HelpText = "Source directory to scan")]
- public string Directory { get; set; } = string.Empty;
-
- [Option('o', "output", HelpText = "Output file path (default: copy to clipboard)")]
- public string? OutputFile { get; set; }
-
- [Option('e', "extensions", Separator = ',',
- HelpText = "File extensions to include (comma-separated, use '*' for auto-detect)")]
- public IEnumerable? Extensions { get; set; }
-
- [Option('r', "max-depth", HelpText = "Maximum directory depth to recurse")]
- public int? MaxDepth { get; set; }
-
- [Option("include", Separator = ',', HelpText = "Include files matching these patterns")]
- public IEnumerable? IncludePatterns { get; set; }
-
- [Option("exclude", Separator = ',', HelpText = "Exclude files/dirs matching these patterns")]
- public IEnumerable? ExcludePatterns { get; set; }
-
- [Option("ignore-dirs", Separator = ',', HelpText = "Additional directories to ignore")]
- public IEnumerable? IgnoreDirs { get; set; }
-
- [Option('c', "compact", HelpText = "Enable compact mode (remove comments, docstrings)")]
- public bool CompactMode { get; set; }
-
- [Option("no-tree", HelpText = "Don't include directory tree in output")]
- public bool NoTree { get; set; }
-
- [Option("max-files", HelpText = "Maximum number of files to process")]
- public int? MaxFiles { get; set; }
-
- [Option("max-file-size", HelpText = "Maximum file size in bytes (default: 10MB)")]
- public long? MaxFileSize { get; set; }
-
- [Option("dry-run", HelpText = "Show what would be processed without actually doing it")]
- public bool DryRun { get; set; }
-
- [Option('v', "verbose", HelpText = "Enable verbose output")]
- public bool Verbose { get; set; }
-}
diff --git a/Configuration/AppConfig.cs b/Configuration/AppConfig.cs
deleted file mode 100644
index 2b86b9f..0000000
--- a/Configuration/AppConfig.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using FileCombiner.CLI;
-
-namespace FileCombiner.Configuration;
-using JetBrains.Annotations;
-
-
-///
-/// Application configuration - single source of truth for defaults.
-///
-[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
-public class AppConfig : ConfigObject
-{
- public string Directory { get; private init; } = string.Empty;
- public string? OutputFile { get; set; }
- public List Extensions { get; set; } = ["*"];
- public int MaxDepth { get; set; } = 5;
- public bool CompactMode { get; private set; }
- public bool IncludeTree { get; private set; } = true;
- public List ExcludePatterns { get; set; } = new();
- public List IncludePatterns { get; set; } = new();
- public HashSet IgnoreDirs { get; private init; } = new();
- public HashSet IgnoreFiles { get; private init; } = new();
- public long MaxFileSize { get; set; } = 10 * 1024 * 1024; // 10MB
- public int MaxTotalFiles { get; set; } = 1000;
- public bool DryRun { get; set; }
- public bool Verbose { get; set; }
-
- // exactly one element equal to "*"
- public bool AutoDetectText => Extensions is ["*"];
-
- public static AppConfig CreateDefault(string directory) => new()
- {
- Directory = directory,
- IgnoreDirs = new HashSet
- {
- "__pycache__", ".git", ".svn", ".hg", ".bzr", "_darcs",
- "node_modules", ".venv", "venv", "env", ".env",
- "build", "dist", ".tox", ".pytest_cache", ".mypy_cache",
- "target", "bin", "obj", ".vs", ".vscode", ".idea",
- "coverage", ".coverage", "htmlcov", ".nyc_output"
- },
- IgnoreFiles = [".DS_Store", "Thumbs.db", "desktop.ini", ".gitkeep"]
- };
-
- public static AppConfig FromCommandLine(CommandLineOptions opts)
- {
- var c = CreateDefault(opts.Directory);
-
- c.OutputFile = UseIfProvided(c.OutputFile, opts.OutputFile);
- c.Extensions = UseIfProvided(c.Extensions, opts.Extensions?.ToList());
- c.MaxDepth = UseIfProvided(c.MaxDepth, opts.MaxDepth);
- c.IncludePatterns = UseIfProvided(c.IncludePatterns, opts.IncludePatterns?.ToList());
- c.ExcludePatterns = UseIfProvided(c.ExcludePatterns, opts.ExcludePatterns?.ToList());
- UnionIfProvided(c.IgnoreDirs, opts.IgnoreDirs);
- c.MaxTotalFiles = UseIfProvided(c.MaxTotalFiles, opts.MaxFiles);
- c.MaxFileSize = UseIfProvided(c.MaxFileSize, opts.MaxFileSize);
-
- c.CompactMode = opts.CompactMode;
- c.IncludeTree = !opts.NoTree;
- c.DryRun = opts.DryRun;
- c.Verbose = opts.Verbose;
-
- c.Extensions = NormalizeExt(c.Extensions);
- return c;
- }
-
- private static List NormalizeExt(List exts)
- {
- // If wildcard is present *alone*, keep it as-is.
- if (exts is ["*"]) return exts;
-
- var outList = new List(exts.Count);
- var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (var raw in exts)
- {
- var e = raw?.Trim();
- if (string.IsNullOrEmpty(e)) continue;
- if (e == "*") { outList.Clear(); outList.Add("*"); return outList; } // treat lone * as special
- if (!e.StartsWith('.')) e = "." + e;
- if (seen.Add(e)) outList.Add(e);
- }
-
- // if ended up empty, fall back to "*"
- return outList.Count > 0 ? outList : new List { "*" };
- }
-}
\ No newline at end of file
diff --git a/Configuration/ConfigObject.cs b/Configuration/ConfigObject.cs
deleted file mode 100644
index 24761a9..0000000
--- a/Configuration/ConfigObject.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-namespace FileCombiner.Configuration;
-
-using System.Collections;
-
-///
-/// Base configuration object providing helpers for merging overrides into defaults.
-///
-public abstract class ConfigObject
-{
- ///
- /// Use source if provided; otherwise keep current (value types).
- ///
- protected static T UseIfProvided(T current, T? source) where T : struct
- => source.HasValue ? source.Value : current;
-
- ///
- /// Use source if provided; for strings ignore null/whitespace; for collections require non-empty; else keep current.
- ///
- protected static T UseIfProvided(T current, T? source) where T : class?
- {
- if (source is null) return current;
-
- if (source is string s)
- return string.IsNullOrWhiteSpace(s) ? current : source;
-
- if (source is IEnumerable e)
- return HasAny(e) ? source : current;
-
- return source;
- }
-
- ///
- /// Merge source into current HashSet if source has any items; always returns current.
- ///
- protected static HashSet UnionIfProvided(HashSet current, IEnumerable? source)
- {
- if (source is null) return current;
- using var en = source.GetEnumerator();
- if (en.MoveNext())
- current.UnionWith(source);
- return current;
- }
-
- private static bool HasAny(IEnumerable e)
- {
- var en = e.GetEnumerator();
- try { return en.MoveNext(); }
- finally { (en as IDisposable)?.Dispose(); }
- }
-}
\ No newline at end of file
diff --git a/FileCombiner.Tests/CLI/CLIOutputTests.cs b/FileCombiner.Tests/CLI/CLIOutputTests.cs
new file mode 100644
index 0000000..8710928
--- /dev/null
+++ b/FileCombiner.Tests/CLI/CLIOutputTests.cs
@@ -0,0 +1,77 @@
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+
+namespace FileCombiner.Tests.CLI;
+
+///
+/// Tests for CLI output functionality
+///
+public class CLIOutputTests
+{
+ [Fact]
+ public void PrintSummary_WithValidResult_DoesNotThrow()
+ {
+ // Arrange
+ var files = new List
+ {
+ new("test1.cs", "/path/test1.cs", 100, 1),
+ new("test2.cs", "/path/test2.cs", 200, 1)
+ };
+ var result = new ProcessResult(files, new List(), new List(), 300, 0, 5);
+
+ // Act & Assert
+ var exception = Record.Exception(() => CommandLineInterface.PrintSummary(result));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void PrintSummary_WithEmptyResult_DoesNotThrow()
+ {
+ // Arrange
+ var result = new ProcessResult(new List(), new List(), new List(), 0, 0, 0);
+
+ // Act & Assert
+ var exception = Record.Exception(() => CommandLineInterface.PrintSummary(result));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OutputResult_WithEmptyContent_DoesNotThrow()
+ {
+ // Arrange
+ var data = new CombinedFilesData(string.Empty, 0);
+ var config = AppConfig.CreateDefault(".");
+
+ // Act & Assert
+ var exception = await Record.ExceptionAsync(async () =>
+ await CommandLineInterface.OutputResult(data, config));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task OutputResult_WithValidContentAndFile_WritesFile()
+ {
+ // Arrange
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ var data = new CombinedFilesData("Test content", 0);
+ var config = AppConfig.CreateDefault(".");
+ config.OutputFile = tempFile;
+
+ // Act
+ await CommandLineInterface.OutputResult(data, config);
+
+ // Assert
+ Assert.True(File.Exists(tempFile));
+ var content = await File.ReadAllTextAsync(tempFile);
+ Assert.Equal("Test content", content);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+}
diff --git a/FileCombiner.Tests/CLI/CommandLineInterfaceTests.cs b/FileCombiner.Tests/CLI/CommandLineInterfaceTests.cs
new file mode 100644
index 0000000..b3d9ede
--- /dev/null
+++ b/FileCombiner.Tests/CLI/CommandLineInterfaceTests.cs
@@ -0,0 +1,234 @@
+using FileCombiner.Modules.CLI;
+using Spectre.Console;
+
+namespace FileCombiner.Tests.CLI;
+
+///
+/// Tests for CommandLineInterface functionality
+///
+public class CommandLineInterfaceTests
+{
+ // Feature: cli-fixes-and-simplification, Property 1: Text escaping prevents markup errors
+ ///
+ /// Property test: For any string containing potential Spectre.Console markup keywords,
+ /// when that string is escaped and displayed, the system should not throw markup parsing exceptions.
+ /// Validates: Requirements 1.2
+ ///
+ [Property(MaxTest = 100)]
+ public Property EscapedTextNeverThrowsMarkupException(string arbitraryText)
+ {
+ // Handle null input
+ if (arbitraryText == null)
+ {
+ arbitraryText = string.Empty;
+ }
+
+ return Prop.ForAll(
+ Arb.Default.String(),
+ text =>
+ {
+ text ??= string.Empty;
+
+ // Escape the text using SafeMarkup
+ var escaped = CommandLineInterface.SafeMarkup(text);
+
+ // Try to render the escaped text - this should never throw
+ var exception = Record.Exception(() =>
+ {
+ // Use Spectre.Console's internal markup parser to validate
+ var markup = new Markup(escaped);
+ // Force evaluation by getting the segments
+ _ = markup.ToString();
+ });
+
+ return exception == null;
+ });
+ }
+
+ [Fact]
+ public void SafeMarkup_EscapesSquareBrackets()
+ {
+ // Arrange
+ var textWithBrackets = "[red]This should be escaped[/]";
+
+ // Act
+ var escaped = CommandLineInterface.SafeMarkup(textWithBrackets);
+
+ // Assert - brackets should be doubled to escape them
+ Assert.Contains("[[", escaped);
+ Assert.Contains("]]", escaped);
+ // And it should not throw when creating Markup
+ var exception = Record.Exception(() => new Markup(escaped));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void SafeMarkup_HandlesEmptyString()
+ {
+ // Arrange
+ var empty = string.Empty;
+
+ // Act
+ var escaped = CommandLineInterface.SafeMarkup(empty);
+
+ // Assert
+ Assert.Equal(string.Empty, escaped);
+ }
+
+ [Theory]
+ [InlineData("[bold]text[/]")]
+ [InlineData("[red]error[/]")]
+ [InlineData("[yellow]warning[/]")]
+ [InlineData("[green]success[/]")]
+ [InlineData("[[escaped]]")]
+ public void SafeMarkup_EscapesCommonMarkupPatterns(string input)
+ {
+ // Act
+ var escaped = CommandLineInterface.SafeMarkup(input);
+
+ // Assert - escaped text should not throw when creating Markup
+ var exception = Record.Exception(() => new Markup(escaped));
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void Parse_WithValidDirectory_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("./src", result.Directory);
+ }
+
+ [Fact]
+ public void Parse_WithExtensions_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "-e", ".cs,.js" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.Extensions);
+ Assert.Contains(".cs", result.Extensions);
+ Assert.Contains(".js", result.Extensions);
+ }
+
+ [Fact]
+ public void Parse_WithOutputFile_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "-o", "output.md" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal("output.md", result.OutputFile);
+ }
+
+ [Fact]
+ public void Parse_WithDryRun_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "--dry-run" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.DryRun);
+ }
+
+ [Fact]
+ public void Parse_WithVerbose_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "--verbose" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.Verbose);
+ }
+
+ [Fact]
+ public void Parse_WithMaxDepth_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "--max-depth", "3" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.Equal(3, result.MaxDepth);
+ }
+
+ [Fact]
+ public void Parse_WithExcludePattern_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "--exclude", "**/bin/**" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotNull(result.ExcludePatterns);
+ Assert.Contains("**/bin/**", result.ExcludePatterns);
+ }
+
+ [Fact]
+ public void Parse_WithNoTree_ReturnsOptions()
+ {
+ // Arrange
+ var args = new[] { "./src", "--no-tree" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(result.NoTree);
+ }
+
+ [Fact]
+ public void Parse_WithInvalidArgs_ReturnsNull()
+ {
+ // Arrange
+ var args = new[] { "--invalid-option" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Parse_WithHelpFlag_ReturnsNull()
+ {
+ // Arrange
+ var args = new[] { "--help" };
+
+ // Act
+ var result = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.Null(result);
+ }
+}
diff --git a/FileCombiner.Tests/CLI/InteractiveModeTests.cs b/FileCombiner.Tests/CLI/InteractiveModeTests.cs
new file mode 100644
index 0000000..9c55aae
--- /dev/null
+++ b/FileCombiner.Tests/CLI/InteractiveModeTests.cs
@@ -0,0 +1,157 @@
+using FileCombiner.Modules.CLI;
+
+namespace FileCombiner.Tests.CLI;
+
+///
+/// Tests for interactive mode behavior
+///
+public class InteractiveModeTests
+{
+ // Feature: cli-fixes-and-simplification, Property 4: Arguments prevent interactive mode
+ ///
+ /// Property test: For any non-empty command-line argument list,
+ /// when the application is started with those arguments, interactive mode should not be entered.
+ /// Validates: Requirements 6.4
+ ///
+ [Property(MaxTest = 100)]
+ public Property ArgumentsPreventInteractiveMode(NonEmptyArray args)
+ {
+ return Prop.ForAll(
+ Arb.Default.NonEmptyArray(),
+ nonEmptyArgs =>
+ {
+ // Filter out help flags which return null
+ var filteredArgs = nonEmptyArgs.Get
+ .Where(arg => arg != null && arg != "-h" && arg != "--help")
+ .ToArray();
+
+ if (filteredArgs.Length == 0)
+ {
+ return true.ToProperty().Label("Skipped: no valid args after filtering");
+ }
+
+ // Parse the arguments
+ var options = CommandLineInterface.Parse(filteredArgs);
+
+ // If parsing succeeded, verify Interactive flag is not set by default
+ if (options != null)
+ {
+ // The Interactive property should only be true if explicitly set via -i or --interactive
+ var hasInteractiveFlag = filteredArgs.Contains("-i") || filteredArgs.Contains("--interactive");
+
+ if (hasInteractiveFlag)
+ {
+ // If interactive flag is present, it should be true
+ return options.Interactive.ToProperty();
+ }
+ else
+ {
+ // If no interactive flag, it should be false (not entering interactive mode)
+ return (!options.Interactive).ToProperty();
+ }
+ }
+
+ // If parsing failed (returned null), that's acceptable for invalid arguments
+ return true.ToProperty();
+ });
+ }
+
+ [Fact]
+ public void Parse_WithNoArguments_DoesNotEnterInteractiveMode()
+ {
+ // Arrange
+ var args = Array.Empty();
+
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert - should return options with defaults, not enter interactive mode
+ Assert.NotNull(options);
+ Assert.False(options.Interactive);
+ Assert.Equal(".", options.Directory); // Default directory
+ }
+
+ [Fact]
+ public void Parse_WithInteractiveFlag_SetsInteractiveTrue()
+ {
+ // Arrange
+ var args = new[] { "--interactive" };
+
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert - Parse should detect interactive flag but not actually run interactive mode
+ // (RunInteractive is called after Parse returns)
+ Assert.NotNull(options);
+ Assert.True(options.Interactive);
+ }
+
+ [Fact]
+ public void Parse_WithShortInteractiveFlag_SetsInteractiveTrue()
+ {
+ // Arrange
+ var args = new[] { "-i" };
+
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.True(options.Interactive);
+ }
+
+ [Fact]
+ public void Parse_WithDirectoryArgument_DoesNotEnterInteractiveMode()
+ {
+ // Arrange
+ var args = new[] { "." };
+
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.False(options.Interactive);
+ Assert.Equal(".", options.Directory);
+ }
+
+ [Fact]
+ public void Parse_WithMultipleArguments_DoesNotEnterInteractiveMode()
+ {
+ // Arrange
+ var args = new[] { ".", "--dry-run", "--verbose" };
+
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.False(options.Interactive);
+ Assert.True(options.DryRun);
+ Assert.True(options.Verbose);
+ }
+
+ [Fact]
+ public void Parse_WithVariousArguments_NeverEntersInteractiveModeUnlessExplicit()
+ {
+ // Arrange - test various argument combinations
+ var testCases = new[]
+ {
+ new[] { ".", "-o", "output.md" },
+ new[] { ".", "-e", ".cs,.js" },
+ new[] { ".", "--dry-run" },
+ new[] { ".", "--verbose" },
+ new[] { ".", "--exclude", "**/*.log" }
+ };
+
+ foreach (var args in testCases)
+ {
+ // Act
+ var options = CommandLineInterface.Parse(args);
+
+ // Assert
+ Assert.NotNull(options);
+ Assert.False(options.Interactive);
+ }
+ }
+}
diff --git a/FileCombiner.Tests/FileCombiner.Tests.csproj b/FileCombiner.Tests/FileCombiner.Tests.csproj
new file mode 100644
index 0000000..e65b5d8
--- /dev/null
+++ b/FileCombiner.Tests/FileCombiner.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/FileCombiner.Tests/Models/RecordModelTests.cs b/FileCombiner.Tests/Models/RecordModelTests.cs
new file mode 100644
index 0000000..9284721
--- /dev/null
+++ b/FileCombiner.Tests/Models/RecordModelTests.cs
@@ -0,0 +1,174 @@
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+
+namespace FileCombiner.Tests.Models;
+
+///
+/// Tests for record model classes
+///
+public class RecordModelTests
+{
+ [Fact]
+ public void ProcessResult_Constructor_InitializesProperties()
+ {
+ // Arrange
+ var files = new List
+ {
+ new("test.cs", "/path/test.cs", 100, 1)
+ };
+ var skippedFiles = new List { "skip1.txt" };
+ var skippedDirs = new List { "node_modules" };
+
+ // Act
+ var result = new ProcessResult(files, skippedFiles, skippedDirs, 1024, 50, 5);
+
+ // Assert
+ Assert.Single(result.ProcessedFiles);
+ Assert.Single(result.SkippedFiles);
+ Assert.Single(result.SkippedDirectories);
+ Assert.Equal(1024, result.TotalSize);
+ Assert.Equal(50, result.TokenCount);
+ Assert.Equal(5, result.FoundDirs);
+ }
+
+ [Fact]
+ public void DiscoveredFile_Properties_SetCorrectly()
+ {
+ // Arrange & Act
+ var file = new DiscoveredFile("src/test.cs", "/project/src/test.cs", 2048, 2);
+
+ // Assert
+ Assert.Equal("src/test.cs", file.RelativePath);
+ Assert.Equal("/project/src/test.cs", file.AbsolutePath);
+ Assert.Equal(2048, file.Size);
+ Assert.Equal(2, file.Depth);
+ Assert.Equal(".cs", file.Extension);
+ Assert.Equal("test.cs", file.Name);
+ }
+
+ [Fact]
+ public void DiscoveredFile_Extension_ReturnsLowerCase()
+ {
+ // Arrange & Act
+ var file = new DiscoveredFile("Test.CS", "/path/Test.CS", 100, 1);
+
+ // Assert
+ Assert.Equal(".cs", file.Extension);
+ }
+
+ [Fact]
+ public void DiscoveredFile_Name_ReturnsFileName()
+ {
+ // Arrange & Act
+ var file = new DiscoveredFile("src/subfolder/test.cs", "/path/src/subfolder/test.cs", 100, 2);
+
+ // Assert
+ Assert.Equal("test.cs", file.Name);
+ }
+
+ [Fact]
+ public void CombinedFilesData_Constructor_InitializesProperties()
+ {
+ // Arrange & Act
+ var data = new CombinedFilesData("test content", 100);
+
+ // Assert
+ Assert.Equal("test content", data.FinalContent);
+ Assert.Equal(100, data.TotalTokens);
+ }
+
+ [Fact]
+ public void AppConfig_CreateDefault_InitializesWithDefaults()
+ {
+ // Arrange & Act
+ var config = AppConfig.CreateDefault("./src");
+
+ // Assert
+ Assert.Equal("./src", config.Directory);
+ Assert.Equal(5, config.MaxDepth);
+ Assert.True(config.IncludeTree);
+ Assert.False(config.DryRun);
+ Assert.False(config.Verbose);
+ Assert.NotEmpty(config.IgnoreDirs);
+ Assert.Contains("node_modules", config.IgnoreDirs);
+ Assert.Contains(".git", config.IgnoreDirs);
+ }
+
+ [Fact]
+ public void AppConfig_FromCommandLine_MapsCorrectly()
+ {
+ // Arrange
+ var options = new Modules.CLI.CommandLineOptions
+ {
+ Directory = "./src",
+ Extensions = new[] { ".cs", ".js" },
+ OutputFile = "output.md",
+ MaxDepth = 3,
+ DryRun = true,
+ Verbose = true,
+ NoTree = true,
+ ExcludePatterns = new[] { "**/bin/**" }
+ };
+
+ // Act
+ var config = AppConfig.FromCommandLine(options);
+
+ // Assert
+ Assert.Equal("./src", config.Directory);
+ Assert.Contains(".cs", config.Extensions);
+ Assert.Contains(".js", config.Extensions);
+ Assert.Equal("output.md", config.OutputFile);
+ Assert.Equal(3, config.MaxDepth);
+ Assert.True(config.DryRun);
+ Assert.True(config.Verbose);
+ Assert.False(config.IncludeTree); // NoTree = true means IncludeTree = false
+ Assert.Contains("**/bin/**", config.ExcludePatterns);
+ }
+
+ [Fact]
+ public void AppConfig_FromCommandLine_HandlesNullExtensions()
+ {
+ // Arrange
+ var options = new Modules.CLI.CommandLineOptions
+ {
+ Directory = "./src",
+ Extensions = null
+ };
+
+ // Act
+ var config = AppConfig.FromCommandLine(options);
+
+ // Assert
+ Assert.NotNull(config.Extensions);
+ Assert.Single(config.Extensions);
+ Assert.Equal("*", config.Extensions[0]);
+ }
+
+ [Fact]
+ public void AppConfig_AutoDetectText_ReturnsTrueForWildcard()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault(".");
+ config.Extensions = new List { "*" };
+
+ // Act
+ var result = config.AutoDetectText;
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void AppConfig_AutoDetectText_ReturnsFalseForSpecificExtensions()
+ {
+ // Arrange
+ var config = AppConfig.CreateDefault(".");
+ config.Extensions = new List { ".cs", ".js" };
+
+ // Act
+ var result = config.AutoDetectText;
+
+ // Assert
+ Assert.False(result);
+ }
+}
diff --git a/FileCombiner.Tests/Services/AsyncOperationsTests.cs b/FileCombiner.Tests/Services/AsyncOperationsTests.cs
new file mode 100644
index 0000000..86bbe15
--- /dev/null
+++ b/FileCombiner.Tests/Services/AsyncOperationsTests.cs
@@ -0,0 +1,163 @@
+using FileCombiner.Modules;
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FileCombiner.Tests.Services;
+
+///
+/// Tests for async operations to ensure they don't deadlock
+///
+public class AsyncOperationsTests
+{
+ // Feature: cli-fixes-and-simplification, Property 3: Async operations complete without deadlock
+ ///
+ /// Property test: For any async operation in the application flow,
+ /// when that operation is awaited, it should complete within a reasonable timeout without deadlocking.
+ /// Validates: Requirements 5.3
+ ///
+ [Property(MaxTest = 100)]
+ public Property AsyncOperationsCompleteWithoutDeadlock(NonEmptyString directoryGen)
+ {
+ return Prop.ForAll(
+ Arb.Default.Bool(),
+ verbose =>
+ {
+ // Use current directory for testing (directoryGen might not exist)
+ var directory = ".";
+
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = directory,
+ Verbose = verbose,
+ MaxDepth = 1,
+ MaxFiles = 5 // Limit files for faster testing
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ using var provider = services.BuildServiceProvider();
+ var discoveryService = provider.GetRequiredService();
+
+ // Act - run async operation with timeout
+ var discoveryTask = discoveryService.DiscoverFilesAsync(config);
+ var completed = discoveryTask.Wait(TimeSpan.FromSeconds(10));
+
+ // Assert
+ if (!completed)
+ {
+ return false.ToProperty().Label("DiscoverFilesAsync timed out after 10 seconds");
+ }
+
+ if (discoveryTask.IsFaulted)
+ {
+ return false.ToProperty().Label($"DiscoverFilesAsync faulted: {discoveryTask.Exception?.Message}");
+ }
+
+ return true.ToProperty();
+ });
+ }
+
+ [Fact]
+ public async Task DiscoverFilesAsync_CompletesWithoutDeadlock()
+ {
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ Verbose = false,
+ MaxDepth = 1,
+ MaxFiles = 10
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ await using var provider = services.BuildServiceProvider();
+ var discoveryService = provider.GetRequiredService();
+
+ // Act
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var result = await discoveryService.DiscoverFilesAsync(config);
+ sw.Stop();
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.True(sw.ElapsedMilliseconds < 5000,
+ $"DiscoverFilesAsync took {sw.ElapsedMilliseconds}ms, which may indicate a performance issue");
+ }
+
+ [Fact]
+ public async Task CombineFilesAsync_CompletesWithoutDeadlock()
+ {
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ Verbose = false,
+ MaxDepth = 1,
+ MaxFiles = 5
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ await using var provider = services.BuildServiceProvider();
+ var discoveryService = provider.GetRequiredService();
+ var combinerService = provider.GetRequiredService();
+
+ var discoveryResult = await discoveryService.DiscoverFilesAsync(config);
+
+ // Act
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var combined = await combinerService.CombineFilesAsync(config, discoveryResult);
+ sw.Stop();
+
+ // Assert
+ Assert.NotNull(combined);
+ Assert.True(sw.ElapsedMilliseconds < 10000,
+ $"CombineFilesAsync took {sw.ElapsedMilliseconds}ms, which may indicate a performance issue");
+ }
+
+ [Fact]
+ public async Task AsyncOperations_UseProperAwaitPatterns()
+ {
+ // This test verifies that async operations don't use .Result or .Wait()
+ // by ensuring they can be awaited without blocking
+
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ MaxDepth = 1,
+ MaxFiles = 3
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ await using var provider = services.BuildServiceProvider();
+ var discoveryService = provider.GetRequiredService();
+
+ // Act - this should not block the thread
+ var task1 = discoveryService.DiscoverFilesAsync(config);
+ var task2 = discoveryService.DiscoverFilesAsync(config);
+
+ await Task.WhenAll(task1, task2);
+
+ // Assert - both tasks completed
+ Assert.True(task1.IsCompletedSuccessfully);
+ Assert.True(task2.IsCompletedSuccessfully);
+ }
+}
diff --git a/FileCombiner.Tests/Services/BinaryFileExtractorTests.cs b/FileCombiner.Tests/Services/BinaryFileExtractorTests.cs
new file mode 100644
index 0000000..3ca9f6c
--- /dev/null
+++ b/FileCombiner.Tests/Services/BinaryFileExtractorTests.cs
@@ -0,0 +1,196 @@
+using FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+namespace FileCombiner.Tests.Services;
+
+///
+/// Tests for binary file text extractors using real test files
+///
+public class BinaryFileExtractorTests
+{
+ private const string TestFixturesPath = "TestFixtures";
+
+ [Fact]
+ public async Task DocxTextExtractor_ThrowsOnInvalidFile()
+ {
+ // Arrange
+ var extractor = new DocxTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "This is not a valid docx file");
+
+ // Act & Assert - should throw on invalid file
+ await Assert.ThrowsAnyAsync(async () =>
+ await extractor.ExtractTextAsync(tempFile));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task ExcelTextExtractor_ThrowsOnInvalidFile()
+ {
+ // Arrange
+ var extractor = new ExcelTextExtractor();
+ var tempFile = Path.ChangeExtension(Path.GetTempFileName(), ".xlsx");
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "This is not a valid excel file");
+
+ // Act & Assert - should throw on invalid file
+ await Assert.ThrowsAnyAsync(async () =>
+ await extractor.ExtractTextAsync(tempFile));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task PdfTextExtractor_ExtractsFromPdfFile()
+ {
+ // Arrange
+ var extractor = new PdfTextExtractor();
+ var testFile = Path.Combine(TestFixturesPath, "file-sample_150kB.pdf");
+
+ // Skip if file doesn't exist
+ if (!File.Exists(testFile))
+ {
+ return;
+ }
+
+ // Act
+ var result = await extractor.ExtractTextAsync(testFile);
+
+ // Assert
+ Assert.NotNull(result);
+ // PDF might be empty or have content
+ }
+
+ [Fact]
+ public async Task PdfTextExtractor_HandlesInvalidFile()
+ {
+ // Arrange
+ var extractor = new PdfTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "This is not a valid PDF file");
+
+ // Act
+ var result = await extractor.ExtractTextAsync(tempFile);
+
+ // Assert - should return empty or error message, not throw
+ Assert.NotNull(result);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task PptxTextExtractor_ExtractsFromPptxFile()
+ {
+ // Arrange
+ var extractor = new PptxTextExtractor();
+ var testFile = Path.Combine(TestFixturesPath, "file_example_PPTX_250kB.pptx");
+
+ // Skip if file doesn't exist
+ if (!File.Exists(testFile))
+ {
+ return;
+ }
+
+ // Act
+ var result = await extractor.ExtractTextAsync(testFile);
+
+ // Assert
+ Assert.NotNull(result);
+ // PPTX might have content or be empty
+ }
+
+ [Fact]
+ public async Task PptxTextExtractor_ThrowsOnInvalidFile()
+ {
+ // Arrange
+ var extractor = new PptxTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "This is not a valid pptx file");
+
+ // Act & Assert - should throw on invalid file
+ await Assert.ThrowsAnyAsync(async () =>
+ await extractor.ExtractTextAsync(tempFile));
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public async Task ExcelTextExtractor_ExtractsFromXlsxFile()
+ {
+ // Arrange
+ var extractor = new ExcelTextExtractor();
+ var testFile = Path.Combine(TestFixturesPath, "file_example_XLSX_50.xlsx");
+
+ // Skip if file doesn't exist
+ if (!File.Exists(testFile))
+ {
+ return;
+ }
+
+ // Act
+ var result = await extractor.ExtractTextAsync(testFile);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.NotEmpty(result);
+ }
+
+ [Fact]
+ public void DocxTextExtractor_CanHandle_SupportsDocx()
+ {
+ // Arrange
+ var extractor = new DocxTextExtractor();
+
+ // Act & Assert
+ Assert.True(extractor.CanHandle(".docx"));
+ Assert.False(extractor.CanHandle(".doc")); // Only supports .docx
+ Assert.False(extractor.CanHandle(".txt"));
+ }
+
+ [Fact]
+ public void ExcelTextExtractor_CanHandle_SupportsMultipleExtensions()
+ {
+ // Arrange
+ var extractor = new ExcelTextExtractor();
+
+ // Act & Assert
+ Assert.True(extractor.CanHandle(".xlsx"));
+ Assert.True(extractor.CanHandle(".xls"));
+ Assert.False(extractor.CanHandle(".txt"));
+ }
+
+ [Fact]
+ public void PptxTextExtractor_CanHandle_SupportsPptx()
+ {
+ // Arrange
+ var extractor = new PptxTextExtractor();
+
+ // Act & Assert
+ Assert.True(extractor.CanHandle(".pptx"));
+ Assert.False(extractor.CanHandle(".ppt")); // Only supports .pptx
+ Assert.False(extractor.CanHandle(".txt"));
+ }
+}
diff --git a/FileCombiner.Tests/Services/FileDiscoveryServiceTests.cs b/FileCombiner.Tests/Services/FileDiscoveryServiceTests.cs
new file mode 100644
index 0000000..e69de29
diff --git a/FileCombiner.Tests/Services/FileTextExtractorTests.cs b/FileCombiner.Tests/Services/FileTextExtractorTests.cs
new file mode 100644
index 0000000..e0c2ab8
--- /dev/null
+++ b/FileCombiner.Tests/Services/FileTextExtractorTests.cs
@@ -0,0 +1,229 @@
+using FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+namespace FileCombiner.Tests.Services;
+
+///
+/// Tests for file text extractor implementations
+///
+public class FileTextExtractorTests
+{
+ [Fact]
+ public void CsvTextExtractor_CanHandle_ReturnsTrueForCsv()
+ {
+ // Arrange
+ var extractor = new CsvTextExtractor();
+
+ // Act
+ var result = extractor.CanHandle(".csv");
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void CsvTextExtractor_CanHandle_ReturnsFalseForOther()
+ {
+ // Arrange
+ var extractor = new CsvTextExtractor();
+
+ // Act
+ var result = extractor.CanHandle(".txt");
+
+ // Assert
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task CsvTextExtractor_ExtractsBasicCsv()
+ {
+ // Arrange
+ var extractor = new CsvTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "Name,Age\nJohn,30\nJane,25");
+
+ // Act
+ var result = await extractor.ExtractTextAsync(tempFile);
+
+ // Assert
+ Assert.Contains("Name", result);
+ Assert.Contains("John", result);
+ Assert.Contains("Jane", result);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void MarkdownTextExtractor_CanHandle_ReturnsTrueForMarkdown()
+ {
+ // Arrange
+ var extractor = new MarkdownTextExtractor();
+
+ // Act
+ var resultMd = extractor.CanHandle(".md");
+
+ // Assert
+ Assert.True(resultMd);
+ }
+
+ [Fact]
+ public async Task MarkdownTextExtractor_ExtractsMarkdown()
+ {
+ // Arrange
+ var extractor = new MarkdownTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "# Header\n\nSome **bold** text.");
+
+ // Act
+ var result = await extractor.ExtractTextAsync(tempFile);
+
+ // Assert
+ Assert.Contains("Header", result);
+ Assert.Contains("bold", result);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void YamlTextExtractor_CanHandle_ReturnsTrueForYaml()
+ {
+ // Arrange
+ var extractor = new YamlTextExtractor();
+
+ // Act
+ var resultYml = extractor.CanHandle(".yml");
+ var resultYaml = extractor.CanHandle(".yaml");
+
+ // Assert
+ Assert.True(resultYml);
+ Assert.True(resultYaml);
+ }
+
+ [Fact]
+ public async Task YamlTextExtractor_ExtractsYaml()
+ {
+ // Arrange
+ var extractor = new YamlTextExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "name: John\nage: 30");
+
+ // Act
+ var result = await extractor.ExtractTextAsync(tempFile);
+
+ // Assert
+ Assert.Contains("name", result);
+ Assert.Contains("John", result);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void HtmlToMarkdownExtractor_CanHandle_ReturnsTrueForHtml()
+ {
+ // Arrange
+ var extractor = new HtmlToMarkdownExtractor();
+
+ // Act
+ var resultHtml = extractor.CanHandle(".html");
+ var resultHtm = extractor.CanHandle(".htm");
+
+ // Assert
+ Assert.True(resultHtml);
+ Assert.True(resultHtm);
+ }
+
+ [Fact]
+ public async Task HtmlToMarkdownExtractor_ExtractsHtml()
+ {
+ // Arrange
+ var extractor = new HtmlToMarkdownExtractor();
+ var tempFile = Path.GetTempFileName();
+ try
+ {
+ await File.WriteAllTextAsync(tempFile, "Title
Content
");
+
+ // Act
+ var result = await extractor.ExtractTextAsync(tempFile);
+
+ // Assert
+ Assert.Contains("Title", result);
+ Assert.Contains("Content", result);
+ }
+ finally
+ {
+ if (File.Exists(tempFile))
+ File.Delete(tempFile);
+ }
+ }
+
+ [Fact]
+ public void DocxTextExtractor_CanHandle_ReturnsTrueForDocx()
+ {
+ // Arrange
+ var extractor = new DocxTextExtractor();
+
+ // Act
+ var result = extractor.CanHandle(".docx");
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void ExcelTextExtractor_CanHandle_ReturnsTrueForExcel()
+ {
+ // Arrange
+ var extractor = new ExcelTextExtractor();
+
+ // Act
+ var resultXlsx = extractor.CanHandle(".xlsx");
+ var resultXls = extractor.CanHandle(".xls");
+
+ // Assert
+ Assert.True(resultXlsx);
+ Assert.True(resultXls);
+ }
+
+ [Fact]
+ public void PdfTextExtractor_CanHandle_ReturnsTrueForPdf()
+ {
+ // Arrange
+ var extractor = new PdfTextExtractor();
+
+ // Act
+ var result = extractor.CanHandle(".pdf");
+
+ // Assert
+ Assert.True(result);
+ }
+
+ [Fact]
+ public void PptxTextExtractor_CanHandle_ReturnsTrueForPptx()
+ {
+ // Arrange
+ var extractor = new PptxTextExtractor();
+
+ // Act
+ var result = extractor.CanHandle(".pptx");
+
+ // Assert
+ Assert.True(result);
+ }
+}
diff --git a/FileCombiner.Tests/Services/PathResolutionTests.cs b/FileCombiner.Tests/Services/PathResolutionTests.cs
new file mode 100644
index 0000000..7c0b44f
--- /dev/null
+++ b/FileCombiner.Tests/Services/PathResolutionTests.cs
@@ -0,0 +1,158 @@
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Services;
+using FsCheck;
+using FsCheck.Xunit;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace FileCombiner.Tests.Services;
+
+public class PathResolutionTests
+{
+ // Feature: cli-fixes-and-simplification, Property 5: Path resolution is location-independent
+ [Property(MaxTest = 100)]
+ public Property PathResolutionIsLocationIndependent()
+ {
+ // Generate valid relative directory paths
+ var validPaths = Gen.Elements(".", "./Modules", "Modules", "Modules/CLI", "./FileCombiner.Tests")
+ .ToArbitrary();
+
+ return Prop.ForAll(validPaths, path =>
+ {
+ try
+ {
+ // Create a service provider
+ var services = new ServiceCollection();
+ services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Critical));
+
+ var opts = new CommandLineOptions { Directory = path, Verbose = false };
+ Modules.RunTimeUtils.ConfigureServices(services, opts);
+
+ var config = AppConfig.CreateDefault(path);
+ Modules.RunTimeUtils.AddTextParser(services, config);
+
+ var provider = services.BuildServiceProvider();
+
+ // Resolve the discovery service
+ var discoveryService = provider.GetRequiredService();
+
+ // Verify the path is resolved relative to current directory
+ // The key test: this should work regardless of where the project is located
+ var absolutePath = Path.GetFullPath(path);
+
+ // The path should be resolvable and not contain hardcoded locations
+ var exists = Directory.Exists(absolutePath);
+
+ // If the directory exists, discovery should work
+ if (exists)
+ {
+ var task = discoveryService.DiscoverFilesAsync(config);
+ var completed = task.Wait(TimeSpan.FromSeconds(5));
+ return completed.ToProperty().Label("Discovery completed within timeout");
+ }
+
+ // If directory doesn't exist, that's fine - we're testing path resolution, not existence
+ return true.ToProperty().Label("Path resolved successfully");
+ }
+ catch (Exception ex)
+ {
+ return false.ToProperty().Label($"Path resolution failed: {ex.Message}");
+ }
+ });
+ }
+
+ [Fact]
+ public void CurrentDirectoryPathResolvesCorrectly()
+ {
+ var path = ".";
+ var absolutePath = Path.GetFullPath(path);
+
+ // Should resolve to current directory
+ Assert.True(Directory.Exists(absolutePath));
+ Assert.Equal(Directory.GetCurrentDirectory(), absolutePath);
+ }
+
+ [Fact]
+ public void RelativePathResolvesCorrectly()
+ {
+ // Find the solution root by looking for the .sln file
+ var currentDir = Directory.GetCurrentDirectory();
+ var solutionRoot = currentDir;
+
+ while (!File.Exists(Path.Combine(solutionRoot, "FileCombiner.sln")) && Directory.GetParent(solutionRoot) != null)
+ {
+ solutionRoot = Directory.GetParent(solutionRoot)!.FullName;
+ }
+
+ var modulesPath = Path.Combine(solutionRoot, "Modules");
+
+ // Should resolve and exist
+ Assert.True(Directory.Exists(modulesPath), $"Modules directory should exist at {modulesPath}");
+ Assert.Contains("Modules", modulesPath);
+ }
+
+ [Fact]
+ public void PathWithoutPrefixResolvesCorrectly()
+ {
+ // Test that Path.GetFullPath resolves relative to current directory
+ var currentDir = Directory.GetCurrentDirectory();
+ var path = "TestSubDir";
+ var absolutePath = Path.GetFullPath(path);
+
+ // Should resolve relative to current directory
+ Assert.StartsWith(currentDir, absolutePath);
+ Assert.EndsWith("TestSubDir", absolutePath);
+ }
+
+ [Fact]
+ public void NoHardcodedPathsInProductionCode()
+ {
+ // Find the solution root
+ var currentDir = Directory.GetCurrentDirectory();
+ var solutionRoot = currentDir;
+
+ while (!File.Exists(Path.Combine(solutionRoot, "FileCombiner.sln")) && Directory.GetParent(solutionRoot) != null)
+ {
+ solutionRoot = Directory.GetParent(solutionRoot)!.FullName;
+ }
+
+ // This test verifies that production source files don't contain hardcoded absolute paths
+ var modulesPath = Path.Combine(solutionRoot, "Modules");
+ var programFile = Path.Combine(solutionRoot, "Program.cs");
+
+ var sourceFiles = Directory.Exists(modulesPath)
+ ? Directory.GetFiles(modulesPath, "*.cs", SearchOption.AllDirectories).ToList()
+ : new List();
+
+ if (File.Exists(programFile))
+ {
+ sourceFiles.Add(programFile);
+ }
+
+ Assert.NotEmpty(sourceFiles);
+
+ foreach (var file in sourceFiles)
+ {
+ var content = File.ReadAllText(file);
+
+ // Check for common hardcoded path patterns (but not in comments or strings that are examples)
+ // We're looking for actual hardcoded paths in code, not in test assertions
+ var lines = content.Split('\n');
+ foreach (var line in lines)
+ {
+ // Skip comments and using statements
+ var trimmed = line.Trim();
+ if (trimmed.StartsWith("//") || trimmed.StartsWith("using "))
+ continue;
+
+ // Check for actual hardcoded Windows paths
+ if (trimmed.Contains("C:\\Users\\") || trimmed.Contains("D:\\"))
+ {
+ Assert.Fail($"Found hardcoded Windows path in {file}: {line}");
+ }
+ }
+ }
+ }
+}
diff --git a/FileCombiner.Tests/Services/ServiceResolutionTests.cs b/FileCombiner.Tests/Services/ServiceResolutionTests.cs
new file mode 100644
index 0000000..4f65a9b
--- /dev/null
+++ b/FileCombiner.Tests/Services/ServiceResolutionTests.cs
@@ -0,0 +1,135 @@
+using FileCombiner.Modules;
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FileCombiner.Tests.Services;
+
+///
+/// Tests for service resolution and initialization
+///
+public class ServiceResolutionTests
+{
+ // Feature: cli-fixes-and-simplification, Property 2: Service resolution completes without hanging
+ ///
+ /// Property test: For any required service type in the DI container,
+ /// when that service is resolved, the operation should complete within a reasonable timeout (5 seconds).
+ /// Validates: Requirements 2.2, 2.4
+ ///
+ [Property(MaxTest = 100)]
+ public Property ServiceResolutionCompletesWithinTimeout()
+ {
+ return Prop.ForAll(
+ Arb.Default.Bool(),
+ verbose =>
+ {
+ // Arrange - create service collection with test configuration
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ Verbose = verbose,
+ MaxDepth = 1
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ using var provider = services.BuildServiceProvider();
+
+ // Act & Assert - resolve each service within timeout
+ var serviceTypes = new[]
+ {
+ typeof(IFileDiscoveryService),
+ typeof(IFileCombinerService),
+ typeof(ITextDetectionService),
+ typeof(ILanguageDetectionService),
+ typeof(ITextMatcherService)
+ };
+
+ foreach (var serviceType in serviceTypes)
+ {
+ var resolutionTask = Task.Run(() => provider.GetRequiredService(serviceType));
+ var completed = resolutionTask.Wait(TimeSpan.FromSeconds(5));
+
+ if (!completed)
+ {
+ return false.ToProperty().Label($"Service {serviceType.Name} resolution timed out");
+ }
+
+ if (resolutionTask.Result == null)
+ {
+ return false.ToProperty().Label($"Service {serviceType.Name} resolved to null");
+ }
+ }
+
+ return true.ToProperty();
+ });
+ }
+
+ [Fact]
+ public void AllRequiredServices_CanBeResolved()
+ {
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ Verbose = false,
+ MaxDepth = 5
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ using var provider = services.BuildServiceProvider();
+
+ // Act & Assert
+ var discoveryService = provider.GetRequiredService();
+ Assert.NotNull(discoveryService);
+
+ var combinerService = provider.GetRequiredService();
+ Assert.NotNull(combinerService);
+
+ var textDetectionService = provider.GetRequiredService();
+ Assert.NotNull(textDetectionService);
+
+ var languageService = provider.GetRequiredService();
+ Assert.NotNull(languageService);
+
+ var matcherService = provider.GetRequiredService();
+ Assert.NotNull(matcherService);
+ }
+
+ [Fact]
+ public void ServiceResolution_DoesNotDeadlock()
+ {
+ // Arrange
+ var options = new CommandLineOptions
+ {
+ Directory = ".",
+ Verbose = false
+ };
+
+ var config = AppConfig.FromCommandLine(options);
+ var services = new ServiceCollection();
+ RunTimeUtils.ConfigureServices(services, options);
+ RunTimeUtils.AddTextParser(services, config);
+
+ // Act - this should complete quickly without deadlock
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ using var provider = services.BuildServiceProvider();
+ var discoveryService = provider.GetRequiredService();
+ var combinerService = provider.GetRequiredService();
+ sw.Stop();
+
+ // Assert - should complete in under 2 seconds
+ Assert.True(sw.ElapsedMilliseconds < 2000,
+ $"Service resolution took {sw.ElapsedMilliseconds}ms, which may indicate a deadlock or performance issue");
+ Assert.NotNull(discoveryService);
+ Assert.NotNull(combinerService);
+ }
+}
diff --git a/FileCombiner.Tests/TestFixtures/file-sample_100kB.doc b/FileCombiner.Tests/TestFixtures/file-sample_100kB.doc
new file mode 100644
index 0000000..9423c5a
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file-sample_100kB.doc differ
diff --git a/FileCombiner.Tests/TestFixtures/file-sample_100kB.rtf b/FileCombiner.Tests/TestFixtures/file-sample_100kB.rtf
new file mode 100644
index 0000000..6e08e5f
--- /dev/null
+++ b/FileCombiner.Tests/TestFixtures/file-sample_100kB.rtf
@@ -0,0 +1,697 @@
+{\rtf1\ansi\deff3\adeflang1025
+{\fonttbl{\f0\froman\fprq2\fcharset0 Times New Roman;}{\f1\froman\fprq2\fcharset2 Symbol;}{\f2\fswiss\fprq2\fcharset0 Arial;}{\f3\froman\fprq2\fcharset0 Liberation Serif{\*\falt Times New Roman};}{\f4\froman\fprq2\fcharset0 Liberation Sans{\*\falt Arial};}{\f5\froman\fprq2\fcharset0 Symbol;}{\f6\froman\fprq2\fcharset0 OpenSymbol{\*\falt Arial Unicode MS};}{\f7\froman\fprq2\fcharset0 DejaVu Sans;}{\f8\froman\fprq2\fcharset0 Open Sans{\*\falt Arial};}{\f9\fnil\fprq2\fcharset0 Droid Sans Fallback;}{\f10\fnil\fprq2\fcharset0 OpenSymbol{\*\falt Arial Unicode MS};}{\f11\fnil\fprq2\fcharset0 DejaVu Sans;}{\f12\fnil\fprq2\fcharset0 Open Sans{\*\falt Arial};}{\f13\fnil\fprq2\fcharset0 FreeSans;}{\f14\fnil\fprq2\fcharset0 Symbol;}}
+{\colortbl;\red0\green0\blue0;\red0\green0\blue255;\red0\green255\blue255;\red0\green255\blue0;\red255\green0\blue255;\red255\green0\blue0;\red255\green255\blue0;\red255\green255\blue255;\red0\green0\blue128;\red0\green128\blue128;\red0\green128\blue0;\red128\green0\blue128;\red128\green0\blue0;\red128\green128\blue0;\red128\green128\blue128;\red192\green192\blue192;\red0\green0\blue10;\red0\green0\blue1;}
+{\stylesheet{\s0\snext0\ql\nowidctlpar\hyphpar0\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\kerning0\loch\f3\fs24\lang1033 Normal;}
+{\s1\sbasedon50\snext1\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs36\alang1081\ab\loch\f4\fs36\lang1033 Heading 1;}
+{\s2\sbasedon50\snext2\ql\nowidctlpar\hyphpar0\sb200\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs32\alang1081\ab\loch\f4\fs32\lang1033 Heading 2;}
+{\s3\sbasedon50\snext3\ql\nowidctlpar\hyphpar0\sb140\sa120\keepn\ltrpar\cf15\b\dbch\af9\langfe2052\dbch\af13\afs28\alang1081\ab\loch\f4\fs28\lang1033 Heading 3;}
+{\*\cs15\snext15 WW8Num1z0;}
+{\*\cs16\snext16 WW8Num1z1;}
+{\*\cs17\snext17 WW8Num1z2;}
+{\*\cs18\snext18 WW8Num1z3;}
+{\*\cs19\snext19 WW8Num1z4;}
+{\*\cs20\snext20 WW8Num1z5;}
+{\*\cs21\snext21 WW8Num1z6;}
+{\*\cs22\snext22 WW8Num1z7;}
+{\*\cs23\snext23 WW8Num1z8;}
+{\*\cs24\snext24 WW8Num2z0;}
+{\*\cs25\snext25 WW8Num2z1;}
+{\*\cs26\snext26 WW8Num2z2;}
+{\*\cs27\snext27 WW8Num2z3;}
+{\*\cs28\snext28 WW8Num2z4;}
+{\*\cs29\snext29 WW8Num2z5;}
+{\*\cs30\snext30 WW8Num2z6;}
+{\*\cs31\snext31 WW8Num2z7;}
+{\*\cs32\snext32 WW8Num2z8;}
+{\*\cs33\snext33\dbch\af10\loch\f5 WW8Num3z0;}
+{\*\cs34\snext34\dbch\af10\loch\f6 WW8Num3z1;}
+{\*\cs35\snext35\dbch\af10\dbch\af10\loch\f6 Bullets;}
+{\*\cs36\snext36\cf9\ul\ulc0\langfe255\alang255\lang255 Internet Link;}
+{\*\cs37\snext37\cf13\ul\ulc0\langfe255\alang255\lang255 Visited Internet Link;}
+{\*\cs38\snext38\dbch\af14 ListLabel 1;}
+{\*\cs39\snext39\dbch\af10 ListLabel 2;}
+{\*\cs40\snext40\b0\dbch\af14\loch\f7\fs21 ListLabel 3;}
+{\*\cs41\snext41\dbch\af10 ListLabel 4;}
+{\*\cs42\snext42\dbch\af10 ListLabel 5;}
+{\*\cs43\snext43\dbch\af14 ListLabel 6;}
+{\*\cs44\snext44\dbch\af10 ListLabel 7;}
+{\*\cs45\snext45\dbch\af10 ListLabel 8;}
+{\*\cs46\snext46\dbch\af14 ListLabel 9;}
+{\*\cs47\snext47\dbch\af10 ListLabel 10;}
+{\*\cs48\snext48\dbch\af10 ListLabel 11;}
+{\*\cs49\snext49\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\loch\f7\fs21 ListLabel 12;}
+{\s50\sbasedon0\snext51\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs28\alang1081\loch\f4\fs28\lang1033 Heading;}
+{\s51\sbasedon0\snext51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033 Text Body;}
+{\s52\sbasedon51\snext52\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033 List;}
+{\s53\sbasedon0\snext53\ql\nowidctlpar\hyphpar0\sb120\sa120\noline\ltrpar\cf17\i\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\ai\loch\f3\fs24\lang1033 Caption;}
+{\s54\sbasedon0\snext54\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033 Index;}
+{\s55\sbasedon0\snext55\ql\nowidctlpar\hyphpar0\li567\ri567\lin567\rin567\fi0\sb0\sa283\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033 Quotations;}
+{\s56\sbasedon50\snext56\qc\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs56\alang1081\ab\loch\f4\fs56\lang1033 Title;}
+{\s57\sbasedon50\snext57\qc\nowidctlpar\hyphpar0\sb60\sa120\keepn\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs36\alang1081\loch\f4\fs36\lang1033 Subtitle;}
+{\s58\sbasedon0\snext58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033 Table Contents;}
+{\s59\sbasedon58\snext59\qc\nowidctlpar\hyphpar0\noline\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\ab\loch\f3\fs24\lang1033 Table Heading;}
+}{\*\listtable{\list\listtemplateid1
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-432\li792}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-576\li936}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-720\li1080}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-864\li1224}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-1008\li1368}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-1152\li1512}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-1296\li1656}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-1440\li1800}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi-1584\li1944}\listid1}
+{\list\listtemplateid2
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u61623 ?;}{\levelnumbers;}\f15\b0\dbch\af14\fi-360\li720}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9702 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li1080}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9642 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li1440}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u61623 ?;}{\levelnumbers;}\f15\dbch\af14\fi-360\li1800}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9702 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li2160}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9642 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li2520}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u61623 ?;}{\levelnumbers;}\f15\dbch\af14\fi-360\li2880}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9702 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li3240}
+{\listlevel\levelnfc23\leveljc0\levelstartat1\levelfollow0{\leveltext \'01\u9642 ?;}{\levelnumbers;}\f16\dbch\af10\fi-360\li3600}\listid2}
+{\list\listtemplateid3
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}
+{\listlevel\levelnfc255\leveljc0\levelstartat1\levelfollow2{\leveltext \'00;}{\levelnumbers;}\fi0\li0}\listid3}
+}{\listoverridetable{\listoverride\listid1\listoverridecount0\ls1}{\listoverride\listid2\listoverridecount0\ls2}{\listoverride\listid3\listoverridecount0\ls3}}{\*\generator LibreOffice/6.0.7.3$Linux_X86_64 LibreOffice_project/00m0$Build-3}{\info{\creatim\yr2017\mo8\dy2\hr11\min9}{\revtim\yr2019\mo9\dy21\hr14\min2}{\printim\yr0\mo0\dy0\hr0\min0}}{\*\userprops}\deftab709
+\hyphauto0\viewscale100
+{\*\pgdsctbl
+{\pgdsc0\pgdscuse451\pgwsxn11906\pghsxn16838\marglsxn1134\margrsxn1134\margtsxn1134\margbsxn1134\pgdscnxt0 Default Style;}}
+\formshade{\*\pgdscno0}\paperh16838\paperw11906\margl1134\margr1134\margt1134\margb1134\sectd\sbknone\sectunlocked1\pgndec\pgwsxn11906\pghsxn16838\marglsxn1134\margrsxn1134\margtsxn1134\margbsxn1134\ftnbj\ftnstart1\ftnrstcont\ftnnar\aenddoc\aftnrstcont\aftnstart1\aftnnrlc\htmautsp
+{\*\ftnsep\chftnsep}\viewbksp1{\*\background{\shp{\*\shpinst{\sp{\sn shapeType}{\sv 1}}{\sp{\sn fillColor}{\sv 16777215}}}}}\pgndec\pard\plain \s56\qc\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs56\alang1081\ab\loch\f4\fs56\lang1033\qc\sb240\sa120{\cbpat8\cbpat8\fs21\rtlch \ltrch\loch
+Lorem ipsum }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s1\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs36\alang1081\ab\loch\f4\fs36\lang1033{\listtext\pard\plain }\ilvl0\ls1 \li792\ri0\lin792\rin0\fi-432\li0\ri0\lin0\rin0\fi-432\sb240\sa120\keepn{\rtlch \ltrch\loch
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc ac faucibus odio. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Vestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum condimentum. }{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b\dbch\af11\ab\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Vivamus dapibus sodales ex, vitae malesuada ipsum cursus convallis. Maecenas sed egestas nulla, ac condimentum orci. }{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Mauris diam felis, vulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus nisl blandit. Integer lacinia ante ac libero lobortis imperdiet. }{\scaps0\caps0\cf1\expnd0\expndtw0\i\b0\dbch\af11\ai\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Nullam mollis convallis ipsum, ac accumsan nunc vehicula vitae. }{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Nulla eget justo in felis tristique fringilla. Morbi sit amet tortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet mauris tempus fringilla.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Maecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b\dbch\af11\ab\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Maecenas non lorem quis tellus placerat varius. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i\b0\dbch\af11\ai\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Nulla facilisi. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\ul\ulc0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Aenean congue fringilla justo ut aliquam. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{{\field{\*\fldinst HYPERLINK "https://products.office.com/en-us/word" }{\fldrslt {\cs36\cf9\ul\ulc0\langfe255\alang255\lang255\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Mauris id ex erat. }{}}}\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Nunc vulputate neque vitae justo facilisis, non condimentum ante sagittis. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Morbi viverra semper lorem nec molestie. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033{\listtext\pard\plain \b0\dbch\af14\loch\f7\fs21 \u61623\'3f\tab}\ilvl0\ls2 \li720\ri0\lin720\rin0\fi-360\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+In non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam est ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat et. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis tristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque scelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam lobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel, ultricies ut purus. Ut facilisis et lacus eu cursus.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+In eleifend velit vitae libero sollicitudin euismod. Fusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci maximus ultricies. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s1\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs36\alang1081\ab\loch\f4\fs36\lang1033{\listtext\pard\plain }\ilvl0\ls1 \li792\ri0\lin792\rin0\fi-432\li0\ri0\lin0\rin0\fi-432\sb240\sa120\keepn{\rtlch \ltrch\loch
+Cras fringilla ipsum magna, in fringilla dui commodo a.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \trowd\trql\trleft53\ltrrow\trrh450\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clbrdrt\brdrs\brdrw5\brdrcf18\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx770\clbrdrt\brdrs\brdrw5\brdrcf18\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx6434\clbrdrt\brdrs\brdrw5\brdrcf18\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx7992\clbrdrt\brdrs\brdrw5\brdrcf18\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clbrdrr\brdrs\brdrw5\brdrcf18\clpadfr3\clpadr55\clcbpat8\cellx9690\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+\cell\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Lorem ipsum}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Lorem ipsum}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Lorem ipsum}\cell\row\pard\trowd\trql\trleft53\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx770\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx6434\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx7992\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clbrdrr\brdrs\brdrw5\brdrcf18\clpadfr3\clpadr55\clcbpat8\cellx9690\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+1}\cell\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+In eleifend velit vitae libero sollicitudin euismod.}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Lorem}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+\cell\row\pard\trowd\trql\trleft53\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx770\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx6434\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx7992\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clbrdrr\brdrs\brdrw5\brdrcf18\clpadfr3\clpadr55\clcbpat8\cellx9690\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+2}\cell\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Cras fringilla ipsum magna, in fringilla dui commodo a.}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Ipsum}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+\cell\row\pard\trowd\trql\trleft53\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx770\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx6434\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx7992\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clbrdrr\brdrs\brdrw5\brdrcf18\clpadfr3\clpadr55\clcbpat8\cellx9690\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+3}\cell\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\ab\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Fusce vitae vestibulum velit. }\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Lorem}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+\cell\row\pard\trowd\trql\trleft53\ltrrow\trpaddft3\trpaddt0\trpaddfl3\trpaddl0\trpaddfb3\trpaddb0\trpaddfr3\trpaddr0\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx770\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx6434\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clpadfr3\clpadr55\clcbpat8\cellx7992\clpadfl3\clpadl55\clbrdrl\brdrs\brdrw5\brdrcf18\clpadft3\clpadt51\clbrdrb\brdrs\brdrw5\brdrcf18\clpadfb3\clpadb55\clbrdrr\brdrs\brdrw5\brdrcf18\clpadfr3\clpadr55\clcbpat8\cellx9690\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+4}\cell\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Etiam vehicula luctus fermentum.}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql{\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Ipsum}\cell\pard\plain \s58\ql\nowidctlpar\hyphpar0\noline\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\intbl\ql\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+\cell\row\pard\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af11\rtlch \ltrch\loch\fs21\loch\f7\hich\af7
+Etiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui. Maecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam, pellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper justo sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo posuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut et pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo imperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem sed turpis imperdiet eleifend sit amet id sapien.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Maecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo ut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.}
+\par \shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbxcolumn\shpbxignore\shpleft2819\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+{\*\flymaincnt5\flyanchor0\flycntnt}{\shp{\*\shpinst\shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbottom2660\shpbxcolumn\shpbxignore\shpleft2819\shpright6819{\sp{\sn shapeType}{\sv 75}}{\sp{\sn wzDescription}{\sv }}{\sp{\sn wzName}{\sv }}{\sp{\sn pib}{\sv {\pict\picscalex100\picscaley100\piccropl0\piccropr0\piccropt0\piccropb0\picw200\pich133\picwgoal4000\pichgoal2660\jpegblip
+ffd8ffe000104a46494600010101004800480000ffe20c584943435f50524f46494c4500010100000c484c696e6f021000006d6e74725247422058595a2007ce
+00020009000600310000616373704d5346540000000049454320735247420000000000000000000000000000f6d6000100000000d32d48502020000000000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000001163707274000001500000003364657363000001840000
+006c77747074000001f000000014626b707400000204000000147258595a00000218000000146758595a0000022c000000146258595a0000024000000014646d
+6e640000025400000070646d6464000002c400000088767565640000034c0000008676696577000003d4000000246c756d69000003f8000000146d6561730000
+040c0000002474656368000004300000000c725452430000043c0000080c675452430000043c0000080c625452430000043c0000080c7465787400000000436f
+70797269676874202863292031393938204865776c6574742d5061636b61726420436f6d70616e79000064657363000000000000001273524742204945433631
+3936362d322e31000000000000000000000012735247422049454336313936362d322e3100000000000000000000000000000000000000000000000000000000
+0000000000000000000000000000000000000000000058595a20000000000000f35100010000000116cc58595a20000000000000000000000000000000005859
+5a200000000000006fa2000038f50000039058595a2000000000000062990000b785000018da58595a2000000000000024a000000f840000b6cf646573630000
+00000000001649454320687474703a2f2f7777772e6965632e636800000000000000000000001649454320687474703a2f2f7777772e6965632e636800000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000000064657363000000000000002e4945432036313936362d
+322e312044656661756c742052474220636f6c6f7572207370616365202d207352474200000000000000000000002e4945432036313936362d322e3120446566
+61756c742052474220636f6c6f7572207370616365202d20735247420000000000000000000000000000000000000000000064657363000000000000002c5265
+666572656e63652056696577696e6720436f6e646974696f6e20696e2049454336313936362d322e3100000000000000000000002c5265666572656e63652056
+696577696e6720436f6e646974696f6e20696e2049454336313936362d322e310000000000000000000000000000000000000000000000000000766965770000
+00000013a4fe00145f2e0010cf140003edcc0004130b00035c9e0000000158595a2000000000004c09560050000000571fe76d65617300000000000000010000
+00000000000000000000000000000000028f0000000273696720000000004352542063757276000000000000040000000005000a000f00140019001e00230028
+002d00320037003b00400045004a004f00540059005e00630068006d00720077007c00810086008b00900095009a009f00a400a900ae00b200b700bc00c100c6
+00cb00d000d500db00e000e500eb00f000f600fb01010107010d01130119011f0125012b01320138013e0145014c0152015901600167016e0175017c0183018b
+0192019a01a101a901b101b901c101c901d101d901e101e901f201fa0203020c0214021d0226022f02380241024b0254025d02670271027a0284028e029802a2
+02ac02b602c102cb02d502e002eb02f50300030b03160321032d03380343034f035a03660372037e038a039603a203ae03ba03c703d303e003ec03f904060413
+0420042d043b0448045504630471047e048c049a04a804b604c404d304e104f004fe050d051c052b053a05490558056705770586059605a605b505c505d505e5
+05f6060606160627063706480659066a067b068c069d06af06c006d106e306f507070719072b073d074f076107740786079907ac07bf07d207e507f8080b081f
+08320846085a086e0882089608aa08be08d208e708fb09100925093a094f09640979098f09a409ba09cf09e509fb0a110a270a3d0a540a6a0a810a980aae0ac5
+0adc0af30b0b0b220b390b510b690b800b980bb00bc80be10bf90c120c2a0c430c5c0c750c8e0ca70cc00cd90cf30d0d0d260d400d5a0d740d8e0da90dc30dde
+0df80e130e2e0e490e640e7f0e9b0eb60ed20eee0f090f250f410f5e0f7a0f960fb30fcf0fec1009102610431061107e109b10b910d710f511131131114f116d
+118c11aa11c911e81207122612451264128412a312c312e31303132313431363138313a413c513e5140614271449146a148b14ad14ce14f01512153415561578
+159b15bd15e0160316261649166c168f16b216d616fa171d17411765178917ae17d217f7181b18401865188a18af18d518fa19201945196b199119b719dd1a04
+1a2a1a511a771a9e1ac51aec1b141b3b1b631b8a1bb21bda1c021c2a1c521c7b1ca31ccc1cf51d1e1d471d701d991dc31dec1e161e401e6a1e941ebe1ee91f13
+1f3e1f691f941fbf1fea20152041206c209820c420f0211c2148217521a121ce21fb22272255228222af22dd230a23382366239423c223f0241f244d247c24ab
+24da250925382568259725c725f726272657268726b726e827182749277a27ab27dc280d283f287128a228d429062938296b299d29d02a022a352a682a9b2acf
+2b022b362b692b9d2bd12c052c392c6e2ca22cd72d0c2d412d762dab2de12e162e4c2e822eb72eee2f242f5a2f912fc72ffe3035306c30a430db3112314a3182
+31ba31f2322a3263329b32d4330d3346337f33b833f1342b3465349e34d83513354d358735c235fd3637367236ae36e937243760379c37d738143850388c38c8
+39053942397f39bc39f93a363a743ab23aef3b2d3b6b3baa3be83c273c653ca43ce33d223d613da13de03e203e603ea03ee03f213f613fa23fe24023406440a6
+40e74129416a41ac41ee4230427242b542f7433a437d43c044034447448a44ce45124555459a45de4622466746ab46f04735477b47c04805484b489148d7491d
+496349a949f04a374a7d4ac44b0c4b534b9a4be24c2a4c724cba4d024d4a4d934ddc4e254e6e4eb74f004f494f934fdd5027507150bb51065150519b51e65231
+527c52c75313535f53aa53f65442548f54db5528557555c2560f565c56a956f75744579257e0582f587d58cb591a596959b85a075a565aa65af55b455b955be5
+5c355c865cd65d275d785dc95e1a5e6c5ebd5f0f5f615fb36005605760aa60fc614f61a261f56249629c62f06343639763eb6440649464e9653d659265e7663d
+669266e8673d679367e9683f689668ec6943699a69f16a486a9f6af76b4f6ba76bff6c576caf6d086d606db96e126e6b6ec46f1e6f786fd1702b708670e0713a
+719571f0724b72a67301735d73b87414747074cc7528758575e1763e769b76f8775677b37811786e78cc792a798979e77a467aa57b047b637bc27c217c817ce1
+7d417da17e017e627ec27f237f847fe5804780a8810a816b81cd8230829282f4835783ba841d848084e3854785ab860e867286d7873b879f8804886988ce8933
+899989fe8a648aca8b308b968bfc8c638cca8d318d988dff8e668ece8f368f9e9006906e90d6913f91a89211927a92e3934d93b69420948a94f4955f95c99634
+969f970a977597e0984c98b89924999099fc9a689ad59b429baf9c1c9c899cf79d649dd29e409eae9f1d9f8b9ffaa069a0d8a147a1b6a226a296a306a376a3e6
+a456a4c7a538a5a9a61aa68ba6fda76ea7e0a852a8c4a937a9a9aa1caa8fab02ab75abe9ac5cacd0ad44adb8ae2daea1af16af8bb000b075b0eab160b1d6b24b
+b2c2b338b3aeb425b49cb513b58ab601b679b6f0b768b7e0b859b8d1b94ab9c2ba3bbab5bb2ebba7bc21bc9bbd15bd8fbe0abe84beffbf7abff5c070c0ecc167
+c1e3c25fc2dbc358c3d4c451c4cec54bc5c8c646c6c3c741c7bfc83dc8bcc93ac9b9ca38cab7cb36cbb6cc35ccb5cd35cdb5ce36ceb6cf37cfb8d039d0bad13c
+d1bed23fd2c1d344d3c6d449d4cbd54ed5d1d655d6d8d75cd7e0d864d8e8d96cd9f1da76dafbdb80dc05dc8add10dd96de1cdea2df29dfafe036e0bde144e1cc
+e253e2dbe363e3ebe473e4fce584e60de696e71fe7a9e832e8bce946e9d0ea5beae5eb70ebfbec86ed11ed9cee28eeb4ef40efccf058f0e5f172f1fff28cf319
+f3a7f434f4c2f550f5def66df6fbf78af819f8a8f938f9c7fa57fae7fb77fc07fc98fd29fdbafe4bfedcff6dffffffdb00430005040404040305040404060505
+06080d0808070708100b0c090d131014131210121214171d1914161c1612121a231a1c1e1f212121141924272420261d202120ffdb0043010506060807080f08
+080f201512152020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020ffc2001108008500
+c803011100021101031101ffc4001c0000010501010100000000000000000000020001030405060708ffc4001a01000301010101000000000000000000000102
+0300040506ffda000c03010002100310000001cef90fb09a72952d6a75cbea4d4e63c8fb5e46b47b7ac13eefbbc600d1ab0076056ce0100416acafcf72f764c5
+b2c88cb7775e3dda7264f5754ed3360db03601aa1102d6c81bcfca398411c56ce32d9d4101c847bb834e8bbcea88916beadd3e3db6907534b4989384bd3c6b57
+b1dcfd09846af1ab3660044e582d96ce0b8c977982f6f3d37b5257036b9fabd4fd0f0a4558fa5e5acf3a5d3e2d4eecbbc3604bdbb9a4db0e6118183157c160f8
+ac5d5a347f15a74410725d6263afe6eaf48eef09d7074996aa0a7cea3eb705d52c4e9e4f68e0af54a83b33289472ae558e432c5c3514af8776343ced624d7f9f
+a3d1a27b2b798614fb94983918fcddbe1fd74c0e9e4d74deede75e4647644513222110b640a0d842bc5deb940e0149f97b7dab8d374f1985b3e945d821a0957c
+a376f95fa3c359e5ed1e7f5773006e8e5132b156216c81607917b60d2a294e7185b8dbd53cdbc926255e8bdaf1d6c2a6347c89f57cf3ea72e3db9b690fbf799d
+368cdd95ca265121608e056f34e8b516a62837675ea796fa5e67a4b9fa6ca6f41fa7f91e7bccf5f3d9f03ba16d27c2d5312d0b197b291d645264e9d652ba065a
+6af66b2a894f30e8e9c12dae94b894e8b92d67cef4adcab6267b3f67e7208d2bad7396f84dabbcb61277424f49d5a2d869a74074899200d18311192691ea47a9
+5453a1e6b75ca97edcd61e3ccf93ee36c8e00d09d1b4e85a117571eaa248c8656ab2e55467d044da33a326916bd36d293cf3aef21e9001471538fc3e857d5061
+015379d829436c2eff003e769c84390442da26d20d608ead92cbeb20a9b20c4ac919908ccf21e7fbce529d6557a79bb6bf0d20451dcace54ca995321c81d9308
+b1a28d9aadaef3d1759f31024aee19d779d799ee994ad49e7d274a9372b6996f64be8b7535802719f013a225866d9d73111632309e82d93054684df84e1f55ca
+46c80c88a8b2d8cb315972332915b886da9914b6cfb2c61c63c50cc0be311780b366c74acd95ca8b21e05945d642b3653c0b006d19138d710ce8c810c6224766
+0483163097af9b9971a73d2e08a8b2cb84a01b239078160632d85b44c076b2bae4d9958098f173833572f51cff00ffc400271000020202020005050101000000
+0000000102000304110512101314152120222330310632ffda0008010100010502af2d5695cb5d26526ce5d7af51597af22a09996abbf1aea178c707337fa6eb
+7a0b73ea42f9f6bc2db338bab55388bc6e341c7e34f6fc79edb8f3dab167b56346e130ccf65c5598bc7d78d67e9e62deb429d9dcdcabee7c44eb5b41e0cea82f
+e5f168193fe8ec2389cff5b87fb39bbbf3d66769b982bdb2291aa8c1e19950bb1eea4d76389c2e6fa4cf0db1fa9ce9390b7cce401d0ed17e4f169b71f00c1e0c
+36398c6eb1c7c3ec1e173c65e16e6ff4e5375aafa6c1779b15a24e213f10fa732916d194a68b9da70f9de93391c3afe9cf7d56ca1a5b88ad2ca1e995640338f4
+e947d2dfcff418718cdce073fd462fe9cfb3ee8cc00bed16b5184ad70b854bea84f553b09dd67710b4cda45d466d268c99c6659c4cda6e4b2bee27713b4dcd89
+b13719beccdb8b65f9c105b9165cd8f474984377dcdf954f835408b0148d7f90afcbdd64a33b2ae4e4b1fb5c38d768386b8cc3c6cfc756f702455c896ae96ea4
+6a7c18287f36c53d2c3d71f27cc46d59735552a0dce3ff00e06364315c6b84f4f6c366e7c464ada3a56b2fed282cb120eb0d9d51722d8320c39680faca8c3954
+439b54f5f5cb6faecacd8b3ad6d0e35261c1130686088ee816c46f0dcdc0e561b0c2e67dd2f6b02d64ec749f6cd88f5abcb53cbf1dee323b0eb9ca557334b4e4
+7508c138dfe7c19d04d389b84fcf710dc04acb5a3a58a2d606b1c960ac1cb7193de38b9ef5c64f7bc09ef58f0729483ef16caf90e46d38eb94cb563574c2cb37
+3e3c35353460b277967f31f1bcf643522e552f90130eae9e8717630f1c4f4f8f3c8a27934cf2699e5d027998c91b35046cdc833d466f9899d5c5bd1e6c78ee6e
+6ccdec16962069d6043111a00c225b6082f58b6033737a9e609e66e7e49f93c3e2796860362c195608996a63d8e5aab3ecdf81f99a86a0604eb06a09a9d60dac
+5bf5058ad3e3c373b426769b9b3e0c81a75759e75cb036fe9fecd6a0fa089f316c6116f9d819a9a3e3b80cdc30995b180fd0209a83e8d4226a76222d8606dc33
+535e061319a7ffc40029110002020103030402020300000000000000010211031012131420210430314122324051425271ffda0008010301013f01e75b459059
+50b244e58d8e71334d367a56a8f54ed32bd9c58b77c8b11b628b123d5cabc1063f5733abc875990eb6675923ac97f473a7f4473a5f44b2a6bdac3e116596633d
+4cae4458f48c1cbe05e95ff90a38e3f08cb15fb445dd6597d88b2cb3e206577310f4f4f3db224a8911ff0051c6bb6cb2cb2cb225963647cb32ba88c43d22c8cb
+7c2c648fdd595d97a5e965912d9bcdc6247a97f88fb704e9d13f0c9331ce9d0c631f7c74a2a8c534677e6bba24ff002858c6467ba23efbd13132cbbf04511c5c
+96ce94e94dacd8cd8c51313fa322a6331ca98fbdca84c4597645511661f102f48e46998da912499e2bc11b26ace338f56cdcc7214af4cdfa98dfe3aa1310b241
+7d9cd0fece7c7fd8b1d1ff0004e421c91bc721b1f91c46b47a58dd884c421116bec9414be096392d28a1c14858d0e28a4645e0b1fb5626290d9e4de5c5fc95ae
+e1e48af937c5fd925e28716b4a36336338d9c6718e2977d965a290c64e7b476fe48cf69d448ea247348e591c9239246f917236c99c7238cd838b2bb28a1bd18f
+4a286b4dc5eb456946d28a36238cd86c66dd1fbf5a5695a228da5e8fddb2fd9bfe4fffc400271100020201030305010101010000000000000102110312132110
+3031042022415132714061ffda0008010201013f019fa66e64bd3325e9e42f4f33624912c523d2637147ac5f230aa5da9cabc0db63b12ae9815cac92349b66d2
+364d9364a92fb256fc9cf6a4ad92545144f846055024210e5a4d6df81636fcb3f87a5f7631268d2513e59055148911e993f48cac4ccb1b565f6a86254868d232
+ae5d18844910e1d084648e8976e86b8a1e2fc1c4998d7cafa3285d26ab921cab1232c3547b715cf4b387e4c985a22bd9432460953a174cd0d2fb504328aa272e
+192969e0dc1e435a3711ad0e478766376ba648ea4557bd2b1aa20b828aa2465f06456c6868960543e04acc78235726648417831c8dc46ea2528485a0f81a50a0
+38d74c5fd13fe855d19465f234c7063848795b3fd383511ff492ff00d28a170596722b39341b6cd2fecd2726a66b3273e08b6852470596597d71792bd898bd96
+7c075fa3a18d53e95eca349a19a24478628d9b4cdb34234c4f81aa3fa5c054fc15d1f5a1a348e2fa210a3621ad46cc4d98fe9b31fd3661fa6d43f4d981b58cdb
+c68bc68de88f39ba9fd8a66b2d76132cd46a2c691a0aaeb451c0ebaea685919b86b1648fd8e4bebda99e7ad96793495dab351657bafdb7d290e3daa1aff81c7b
+147fffc40037100001020304080305090100000000000001000203112110223132041213203041517105618123339192a134404450627282b1d1e1ffda000801
+0100063f02ca665652b036628de46ab1512478545576b1574ea8b5bf3599164591645902c817bb0aeb648b982a78520713bbf4dc9b96799f24760c97741e7373
+e2b61f4aee3021e75dc737a84e6bacd471f671388544f2a6e39dd0296eed00b263109b337db43c329d10733357a96877533de2d4e866c13371f42811c222d98c
+100534746efed982d0c71bcda70836caad935426cb9a975aee636b9a5399635d3ba6850703c09a701c954ad56299c56b72689a23a53708aa2f72f6508a94766a
+29b44d652a8d280ab82a43430015ec6dd6d6a5ae76ccd55682d73bcd4f665642b2158aaaab66a90fe8a90dc7b291d0dcab064b0540aac0b2a92aacca86c9b5f5
+1c955640561257627c56a8a96a93db3dcc563662b321227d148cfe3b955564c750ba85fed0aeaa8d3eaae43791d94e2682f1e6da2d6911e4f0b5ccbd13a2c8c8
+d2da1b3c9557557184a996482241527c6d4eed2bed7f0615efe21ed08afc43bb4357745d31dfc153c374b3e8a70fc0e2cfd02bbe06ff009d4a1781fab9ca7a4b
+2143fd2caabbae7f7bcbbfb5592af00a9c58a18cef5283585a02019a4ecc04369126eea1722b92e4b95b8aff008aec37395c820775388dd76f4064af35d0fb85
+4703c19852598ac4aa2a99aad15372eae4ab6cc2bb10faabc26ab4536b95ec787457adc38375df70afe49fffc400281000030001030304020301010000000000
+00011121314161105171208191a1b1c1d1e1f030f1ffda0008010100013f21350b67f11946af629e589cb0d081f4acc914840cd036363f53549a37b9ed0742d9
+e0c759bac6d3c1a175adf8e8a2b47c1d90e30dbb3e0ffc01cc35bf88fd043766e9b0d8dfaf4e0e06f8285d2b3f22302d122bd28bb22e479a8adb2119c3656eca
+70bd98d8fa31b2fa9b8a97ab4518af4e93ca159ccfd06f18572c323472e5c2f86216d32946c6fd7745a275309a2a63078105ee6226d8e84ea1f6b086e6658ca9
+fa0084bd6fa6af819836dcc29c2d09794e8f83225facb4fb18a242e8f52b830431e0cc3178301a55a8bff1e56c15aa16bc19a7034b0cb2acaaf96265213a2d0c
+4b69af49b2756a8eee784c5e9a5296a3928c2da0b29b753b2468b979c9f8fd0bbc4f725af495d188b04d56a1ca58b81b35f041265a68e53948ee41cc5b71a255
+884c1806301d12d87f717baee3235941b99fd0584f032aa3a450d6178d847715eb0813685b98bf73f8cc6a19914d9b197535dd94eee4516a84eb44b0ab7b12ec
+79379c8af2dab52376f68d207112435bd1de64a455e4d60909b20f818a310d01f608f7c2217bc93486bf9670508741b16bf186f59291865d19195028b2b1e9ba
+c861b0740b3ca1bf94bb9a7b78b1cdae105ac41b6d77625c15ba34a8636868a3a411455e4677e84dbe45802a1acd72317f627da8e1468ecf71e97eaef23cab16
+a269e4b5ca9cdb007a0978359f935135b27ec5e169921e40370a497217ba29065ca94d6b50e74dec7414cd062fd435ea9e4d29a0ccd42d7036af2356ccbe25c0
+e32cb6fd0423aff8f8124c27a7bc099f426bf7326a2a5b90abadbeed3f826c2e025f26507ee37c96db35eff900ac4d5b1c1d092d887aa38b185387ba1a771ee6
+a9fd1859181558e893171b3a256b125772824cad36ce61a1af8a33dd5ec7f9412fb7d1e1f92aad7e4e27b5185f14a1f7d21e4c1e95271feb22ef8f627956cc57
+b929ca409cb0f28bd4c8f587d0471911453f229334e480d8c6c1c9c80d4744e2860778d0f413e469968124da57b8db6a36211f7583631db23169f03b81c8c98a
+3268522b794f27784bb191289b1da44190c5f5cd121a73237664482920cfa0d2e8347a1c827dce00dc23d6e4904c793429204fa3a15137b928d223c1cb8878c3
+a375a31a87ca141f507eb2b746ba309485d48592040c2d0be9d88431a8c21878e48fffda000c030100020003000000106b0631a8f6e44e0c76cfc6a197c34b9c
+d5a5e0fb54038412dabf4d200da8ebcf66ea2642070e2ecb9e6ef3daaf46c64184b704900e32fd4ed133cd5d6d80b812412cbe809ae198e77100949233ff002e
+20ddb23398c9b0917cb5da4fe182bd6b94f1d5ab100351287c7902e5b68139144dd8dc3e3dfbc7e8199d0098cd819fcd9cdd48ab2249280e782fc18ff9527a96
+29a4292210f465cc52715aba0ea39c6ebfffc400271101010100020202010304030000000000010011213110415161207191f03081b1d1c1e1f1ffda00080103
+01013f104389cc21c966bb8b6ddacdbb30e6e1760317682083f226bd220c3823aa4ead38b03fa0ff009f083a09fabc1fa37d65a708bdc374189867bb39820fcb
+21cfe2c672e5900fd5f13966f2139790bd8fdce02e184441e37f003e0359e01702e4ce9c8e66d0fae3c5cd93bee548c266afa7fcda31820ba967f002116b1cae
+5c401e28190d75f05360df682ce5bdd2504f71e0b2ccdf0d8847cc03a472e6757b9b1c1fa7efff0050b2596db9974cd62e4b9b74dc65294b2db6c30ca1b2cbe4
+4c0b7112c96d89e3708f64e79653ee52cb2cb6db107cc11db166992afecff3f7956a1babebb1ec9336f6adf2571cf4ca665996d806b6c693e2316f81e27b2e27
+df3fbc4610c28c4625c382f62678cfce4c08632d91c7814eed516ba5b08788d679e0e667cc46407d2c3d6ec246905c6cb4d5847521f5690c0507cc07b9c81663
+a10cb0624b3af0294a224575e37773bee79e463d486a41f57c39f8eed92b64cb2db6cbe46123ede5053211d42e98e82dac664b938bb865bb0e522a771236dfe3
+fead3ff1ff0057f2e7c1ab5768ce7af1b16c30afb453e47ac1acd36777399d38253d4bfa95f536c296deaf84c27647cdb21c42ecf036186356bc0bb2b94e4891
+ebc0697dac3259b6885e00f86acbddadf56d90109ee594b2ccccc925996dbe0b3c02d41f3620b84060782ca6d999667c659e062134f231110c2b65999966dfe8
+0c30dbe06186dbffc4002511010101000203000104020300000000000100111031214151a120618191b1d1c1e1f1ffda0008010201013f10eede090f86cde188
+f522993dcc8e692bb9c16df12cf19659270fe33c87715e178409e6daf81ff5c06bb6036d03d36becfb6c7460fbac5870f1965924927058274c709b8fdff88c61
+1f6ba664e8d58599e72cb2c9249b4f3fa20e05fd99fdff00d42110cf08009c5e3fd91b2db780b2cb2c9261f1c26cce08e118897cf01c3727e45e2d9cf32a1e9b
+620820b2cb2c9274842e92463c30bf86e144125a31eac10e2ebbb20fb11041671964969772ed7a45b89642fde18390e9790f00bce9d30701071965925dec2d3d
+465aca26c40fe5261b2e15a0dc9f00b098237252c6218638c25584cb1b133bb116c3a7db6794dff3e61acb6789312be7b00796cbdd9f6f1c09e586e9d41eac5b
+3d65e1e5115347867833a3f69cee5f0caf575accfd96badbcbb82d59d3b0e6c1c3f90fa12f9914a60dd1bc935ead1eecedfb26b5f6bc026df0c2b82a19698585
+90e5919a5911213de98888076cabb603a8b599fd5e4e136193931d3abc7026143f46c91a93c197af01f6ff008ff71ee7f25876fe4b07b3fbbc38fe565ece5384
+895f778982cfc5ea58c2f58bcac1e0bacf51f68fb22d8c3ff75fbbf982f7f9b56afe63af927d30fa9ec43f4dfbad271e999725d8b320d9cee17636a7ee0bdcff
+0036bd4bedc6c6a71df05e9c9d46f623ea2e4c16c84478eb852d8ec6c42a9da7e251df1bfa326ce0642f76271645bc1e235c78b3e43f6268cb4a489c6fe878ce
+05267031c8f1bceb0c360c1271b6f19044ffc400261001000202010305000203000000000000010011213141516171108191a1b1c1e1d1f0f1ffda0008010100
+013f106a73081d65206d754f9e528f6a31aa8a2417d25049ef0c54150cf760d9b8d3da69f12ff497a1083061b1dc4ea2a1babdc7d60e1e5f78b969955bb8c17c
+acb2b91f134fd21e710016fb200a3e385383f69b3f8517b53f647b0a1e84b0b8b059901a9b0b2e817ea0bea420f58a4c3823486dfc8f7b6b65611c16e282ad41
+19940776a3fb25a306218e8a7955411a7ba1a06ca6d7f04d18d4e0852ac688bd07d443d2e7a08075ac0f2d1f8ca2cd46d45cb20eb0ea2cf8c67f89627917bebe
+aa0b187042585d2186a5dc2c05e652c28971c152e3858611136465f4192199999832e0c2b9ac4c7a49ceabfbb950b8fb6287494617479963f2574800f68ac954
+09783a605f03907112399429a85b12565a615e6ce630c36f41a95a8b2e5c194a3a6fd414000f55b9bb0b1981613acc65db29de86f67f83efd16c4a48220950f5
+a554099b5972455371298c1781e18004036730bc1bf55972e5cb9443a7de56098841cc5d79911e91f4e6acc5aa03f90fe7a54a8461182d89b65ce86c88ac30c8
+962710ac42a5729c3e8692e2e22cbf51a0b0ccd9542cadc0c4036cca42a44a7c4bcd6c1b7eae5460cf6b81f16f79c28c18870d546bd23acdccb13307c59ab942
+42df18cb2f1820de29e6186122303dc09c65bc23d6947183282b2f451084e2a5f8a9f76e2b35d6ae38b4d5e04afce655b61ae513f0fd967e5575d453f631516f
+a06e2ba8e405e298e834be60758d11b85310e3710b2a106d48a8b5ce5091017820850bd584af37a038250c9e59596f726ea68f8ce66dd656058a7497b36d406e
+1a45a95728226bd721cc1c415d8a5825059302b022e3761d0387bc52e2594ab58b51531a9530f78d4a08232c15fb91101744b218b788540f7a181e68f50f961b
+bf662621fad162bb827544c5ae521f1a1a5ea092d4a4ea471431d259e9f80cc415172d6a16e501a5973f3a992b7804b4546570a2e2cfd90ce1e4e2c4bf8b9a97
+8aadf785dd6bb12985f45e21535f2c3c08f0c7758f94e2bf12ed50ee131c55d897e308bb1fd4b6fcf65528176bde07af9a5e501ed0ce068e122ceed9f83679d4
+6b0484094ecde6215300ad41d7fac4c933b6781b8a42f217e046258aacdbe496e20bb1b672d388e62054001cda95de51511558e3748665ca8cf4581969ced4f6
+96590f78a6d5792e3fc119468afa89b9d8b648bb674da00d95e0b8ab869188f4bd40fdf68003e61d3460a2b59a8ad18ca6fd930ccbdc7e88a63c95fccb74bc0b
+f6156475c2cd48991b175b90edbadb71e0081581c15742794109341b05e5d07b0cbe97b5ff00b595ed53722e09798bc63f0090b180f6250d08e82cb0643ce670
+d18cb7c29cc23b12b657de39758e344c8103a071e61775a1ff00d599a4401dd36e4ed070095f113350f1275bf92a9156bfe728b1ef7fc41f47b7fa4ca01ef7fe
+100cfe12ff006da75f88970b4ea2fc852a4e88fb86a7466d622be521be5ab98523ac7f0b21648ed4e52a591cd2d805f9964ca8e0b83e503d79966f50dc0bd26f
+a83d1a96885ec6f8978b8b16912248d6b1122abc434dfd095a498e8c54ee864a3bd457b116f09ed2fb21c54ab168350ff12b27cebea53d81051043975300bb51
+999345092c84f28330c75876435eb8260bc01e605c2e183044b8a60458c572e63c13da353dc403097c4a76107883c1021a22e6611790992753c896e44cf5aba9
+1fa216094904236f31846e62ba865704e489c910b51299c4af5348458c449885f246505b4251fc881b090738778210c41070d44bc45e6386483d677263151c16
+41ed0eb10c36466a0ed1259166a013094d88a6c83e210c4b2c6e12e67040992585826462a3ac5a84b89527ffd9}}}}}
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s2\ql\nowidctlpar\hyphpar0\sb200\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs32\alang1081\ab\loch\f4\fs32\lang1033{\listtext\pard\plain }\ilvl1\ls1 \li936\ri0\lin936\rin0\fi-576\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{{\*\bkmkstart __DdeLink__109_736781840}\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8{\*\bkmkend __DdeLink__109_736781840}
+Maecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo ut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+In non mauris justo. Duis vehicula mi vel mi pretium, a viverra erat efficitur. Cras aliquam est ac eros varius, id iaculis dui auctor. Duis pretium neque ligula, et pulvinar mi placerat et. Nulla nec nunc sit amet nunc posuere vestibulum. Ut id neque eget tortor mattis tristique. Donec ante est, blandit sit amet tristique vel, lacinia pulvinar arcu. Pellentesque scelerisque fermentum erat, id posuere justo pulvinar ut. Cras id eros sed enim aliquam lobortis. Sed lobortis nisl ut eros efficitur tincidunt. Cras justo mi, porttitor quis mattis vel, ultricies ut purus. Ut facilisis et lacus eu cursus.}{\rtlch \ltrch\loch
+In eleifend velit vitae libero sollicitudin euismod. }
+\par \shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbxcolumn\shpbxignore\shpleft2819\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+{\*\flymaincnt5\flyanchor0\flycntnt}{\shp{\*\shpinst\shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbottom2660\shpbxcolumn\shpbxignore\shpleft2819\shpright6819{\sp{\sn shapeType}{\sv 75}}{\sp{\sn wzDescription}{\sv }}{\sp{\sn wzName}{\sv }}{\sp{\sn pib}{\sv {\pict\picscalex100\picscaley100\piccropl0\piccropr0\piccropt0\piccropb0\picw200\pich133\picwgoal4000\pichgoal2660\jpegblip
+ffd8ffe000104a46494600010101004800480000ffe20c584943435f50524f46494c4500010100000c484c696e6f021000006d6e74725247422058595a2007ce
+00020009000600310000616373704d5346540000000049454320735247420000000000000000000000000000f6d6000100000000d32d48502020000000000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000001163707274000001500000003364657363000001840000
+006c77747074000001f000000014626b707400000204000000147258595a00000218000000146758595a0000022c000000146258595a0000024000000014646d
+6e640000025400000070646d6464000002c400000088767565640000034c0000008676696577000003d4000000246c756d69000003f8000000146d6561730000
+040c0000002474656368000004300000000c725452430000043c0000080c675452430000043c0000080c625452430000043c0000080c7465787400000000436f
+70797269676874202863292031393938204865776c6574742d5061636b61726420436f6d70616e79000064657363000000000000001273524742204945433631
+3936362d322e31000000000000000000000012735247422049454336313936362d322e3100000000000000000000000000000000000000000000000000000000
+0000000000000000000000000000000000000000000058595a20000000000000f35100010000000116cc58595a20000000000000000000000000000000005859
+5a200000000000006fa2000038f50000039058595a2000000000000062990000b785000018da58595a2000000000000024a000000f840000b6cf646573630000
+00000000001649454320687474703a2f2f7777772e6965632e636800000000000000000000001649454320687474703a2f2f7777772e6965632e636800000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000000064657363000000000000002e4945432036313936362d
+322e312044656661756c742052474220636f6c6f7572207370616365202d207352474200000000000000000000002e4945432036313936362d322e3120446566
+61756c742052474220636f6c6f7572207370616365202d20735247420000000000000000000000000000000000000000000064657363000000000000002c5265
+666572656e63652056696577696e6720436f6e646974696f6e20696e2049454336313936362d322e3100000000000000000000002c5265666572656e63652056
+696577696e6720436f6e646974696f6e20696e2049454336313936362d322e310000000000000000000000000000000000000000000000000000766965770000
+00000013a4fe00145f2e0010cf140003edcc0004130b00035c9e0000000158595a2000000000004c09560050000000571fe76d65617300000000000000010000
+00000000000000000000000000000000028f0000000273696720000000004352542063757276000000000000040000000005000a000f00140019001e00230028
+002d00320037003b00400045004a004f00540059005e00630068006d00720077007c00810086008b00900095009a009f00a400a900ae00b200b700bc00c100c6
+00cb00d000d500db00e000e500eb00f000f600fb01010107010d01130119011f0125012b01320138013e0145014c0152015901600167016e0175017c0183018b
+0192019a01a101a901b101b901c101c901d101d901e101e901f201fa0203020c0214021d0226022f02380241024b0254025d02670271027a0284028e029802a2
+02ac02b602c102cb02d502e002eb02f50300030b03160321032d03380343034f035a03660372037e038a039603a203ae03ba03c703d303e003ec03f904060413
+0420042d043b0448045504630471047e048c049a04a804b604c404d304e104f004fe050d051c052b053a05490558056705770586059605a605b505c505d505e5
+05f6060606160627063706480659066a067b068c069d06af06c006d106e306f507070719072b073d074f076107740786079907ac07bf07d207e507f8080b081f
+08320846085a086e0882089608aa08be08d208e708fb09100925093a094f09640979098f09a409ba09cf09e509fb0a110a270a3d0a540a6a0a810a980aae0ac5
+0adc0af30b0b0b220b390b510b690b800b980bb00bc80be10bf90c120c2a0c430c5c0c750c8e0ca70cc00cd90cf30d0d0d260d400d5a0d740d8e0da90dc30dde
+0df80e130e2e0e490e640e7f0e9b0eb60ed20eee0f090f250f410f5e0f7a0f960fb30fcf0fec1009102610431061107e109b10b910d710f511131131114f116d
+118c11aa11c911e81207122612451264128412a312c312e31303132313431363138313a413c513e5140614271449146a148b14ad14ce14f01512153415561578
+159b15bd15e0160316261649166c168f16b216d616fa171d17411765178917ae17d217f7181b18401865188a18af18d518fa19201945196b199119b719dd1a04
+1a2a1a511a771a9e1ac51aec1b141b3b1b631b8a1bb21bda1c021c2a1c521c7b1ca31ccc1cf51d1e1d471d701d991dc31dec1e161e401e6a1e941ebe1ee91f13
+1f3e1f691f941fbf1fea20152041206c209820c420f0211c2148217521a121ce21fb22272255228222af22dd230a23382366239423c223f0241f244d247c24ab
+24da250925382568259725c725f726272657268726b726e827182749277a27ab27dc280d283f287128a228d429062938296b299d29d02a022a352a682a9b2acf
+2b022b362b692b9d2bd12c052c392c6e2ca22cd72d0c2d412d762dab2de12e162e4c2e822eb72eee2f242f5a2f912fc72ffe3035306c30a430db3112314a3182
+31ba31f2322a3263329b32d4330d3346337f33b833f1342b3465349e34d83513354d358735c235fd3637367236ae36e937243760379c37d738143850388c38c8
+39053942397f39bc39f93a363a743ab23aef3b2d3b6b3baa3be83c273c653ca43ce33d223d613da13de03e203e603ea03ee03f213f613fa23fe24023406440a6
+40e74129416a41ac41ee4230427242b542f7433a437d43c044034447448a44ce45124555459a45de4622466746ab46f04735477b47c04805484b489148d7491d
+496349a949f04a374a7d4ac44b0c4b534b9a4be24c2a4c724cba4d024d4a4d934ddc4e254e6e4eb74f004f494f934fdd5027507150bb51065150519b51e65231
+527c52c75313535f53aa53f65442548f54db5528557555c2560f565c56a956f75744579257e0582f587d58cb591a596959b85a075a565aa65af55b455b955be5
+5c355c865cd65d275d785dc95e1a5e6c5ebd5f0f5f615fb36005605760aa60fc614f61a261f56249629c62f06343639763eb6440649464e9653d659265e7663d
+669266e8673d679367e9683f689668ec6943699a69f16a486a9f6af76b4f6ba76bff6c576caf6d086d606db96e126e6b6ec46f1e6f786fd1702b708670e0713a
+719571f0724b72a67301735d73b87414747074cc7528758575e1763e769b76f8775677b37811786e78cc792a798979e77a467aa57b047b637bc27c217c817ce1
+7d417da17e017e627ec27f237f847fe5804780a8810a816b81cd8230829282f4835783ba841d848084e3854785ab860e867286d7873b879f8804886988ce8933
+899989fe8a648aca8b308b968bfc8c638cca8d318d988dff8e668ece8f368f9e9006906e90d6913f91a89211927a92e3934d93b69420948a94f4955f95c99634
+969f970a977597e0984c98b89924999099fc9a689ad59b429baf9c1c9c899cf79d649dd29e409eae9f1d9f8b9ffaa069a0d8a147a1b6a226a296a306a376a3e6
+a456a4c7a538a5a9a61aa68ba6fda76ea7e0a852a8c4a937a9a9aa1caa8fab02ab75abe9ac5cacd0ad44adb8ae2daea1af16af8bb000b075b0eab160b1d6b24b
+b2c2b338b3aeb425b49cb513b58ab601b679b6f0b768b7e0b859b8d1b94ab9c2ba3bbab5bb2ebba7bc21bc9bbd15bd8fbe0abe84beffbf7abff5c070c0ecc167
+c1e3c25fc2dbc358c3d4c451c4cec54bc5c8c646c6c3c741c7bfc83dc8bcc93ac9b9ca38cab7cb36cbb6cc35ccb5cd35cdb5ce36ceb6cf37cfb8d039d0bad13c
+d1bed23fd2c1d344d3c6d449d4cbd54ed5d1d655d6d8d75cd7e0d864d8e8d96cd9f1da76dafbdb80dc05dc8add10dd96de1cdea2df29dfafe036e0bde144e1cc
+e253e2dbe363e3ebe473e4fce584e60de696e71fe7a9e832e8bce946e9d0ea5beae5eb70ebfbec86ed11ed9cee28eeb4ef40efccf058f0e5f172f1fff28cf319
+f3a7f434f4c2f550f5def66df6fbf78af819f8a8f938f9c7fa57fae7fb77fc07fc98fd29fdbafe4bfedcff6dffffffdb00430005040404040305040404060505
+06080d0808070708100b0c090d131014131210121214171d1914161c1612121a231a1c1e1f212121141924272420261d202120ffdb0043010506060807080f08
+080f201512152020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020ffc2001108008500
+c803011100021101031101ffc4001c0000010501010100000000000000000000020001030405060708ffc4001a01000301010101000000000000000000000102
+0300040506ffda000c03010002100310000001cef90fb09a72952d6a75cbea4d4e63c8fb5e46b47b7ac13eefbbc600d1ab0076056ce0100416acafcf72f764c5
+b2c88cb7775e3dda7264f5754ed3360db03601aa1102d6c81bcfca398411c56ce32d9d4101c847bb834e8bbcea88916beadd3e3db6907534b4989384bd3c6b57
+b1dcfd09846af1ab3660044e582d96ce0b8c977982f6f3d37b5257036b9fabd4fd0f0a4558fa5e5acf3a5d3e2d4eecbbc3604bdbb9a4db0e6118183157c160f8
+ac5d5a347f15a74410725d6263afe6eaf48eef09d7074996aa0a7cea3eb705d52c4e9e4f68e0af54a83b33289472ae558e432c5c3514af8776343ced624d7f9f
+a3d1a27b2b798614fb94983918fcddbe1fd74c0e9e4d74deede75e4647644513222110b640a0d842bc5deb940e0149f97b7dab8d374f1985b3e945d821a0957c
+a376f95fa3c359e5ed1e7f5773006e8e5132b156216c81607917b60d2a294e7185b8dbd53cdbc926255e8bdaf1d6c2a6347c89f57cf3ea72e3db9b690fbf799d
+368cdd95ca265121608e056f34e8b516a62837675ea796fa5e67a4b9fa6ca6f41fa7f91e7bccf5f3d9f03ba16d27c2d5312d0b197b291d645264e9d652ba065a
+6af66b2a894f30e8e9c12dae94b894e8b92d67cef4adcab6267b3f67e7208d2bad7396f84dabbcb61277424f49d5a2d869a74074899200d18311192691ea47a9
+5453a1e6b75ca97edcd61e3ccf93ee36c8e00d09d1b4e85a117571eaa248c8656ab2e55467d044da33a326916bd36d293cf3aef21e9001471538fc3e857d5061
+015379d829436c2eff003e769c84390442da26d20d608ead92cbeb20a9b20c4ac919908ccf21e7fbce529d6557a79bb6bf0d20451dcace54ca995321c81d9308
+b1a28d9aadaef3d1759f31024aee19d779d799ee994ad49e7d274a9372b6996f64be8b7535802719f013a225866d9d73111632309e82d93054684df84e1f55ca
+46c80c88a8b2d8cb315972332915b886da9914b6cfb2c61c63c50cc0be311780b366c74acd95ca8b21e05945d642b3653c0b006d19138d710ce8c810c6224766
+0483163097af9b9971a73d2e08a8b2cb84a01b239078160632d85b44c076b2bae4d9958098f173833572f51cff00ffc400271000020202020005050101000000
+0000000102000304110512101314152120222330310632ffda0008010100010502af2d5695cb5d26526ce5d7af51597af22a09996abbf1aea178c707337fa6eb
+7a0b73ea42f9f6bc2db338bab55388bc6e341c7e34f6fc79edb8f3dab167b56346e130ccf65c5598bc7d78d67e9e62deb429d9dcdcabee7c44eb5b41e0cea82f
+e5f168193fe8ec2389cff5b87fb39bbbf3d66769b982bdb2291aa8c1e19950bb1eea4d76389c2e6fa4cf0db1fa9ce9390b7cce401d0ed17e4f169b71f00c1e0c
+36398c6eb1c7c3ec1e173c65e16e6ff4e5375aafa6c1779b15a24e213f10fa732916d194a68b9da70f9de93391c3afe9cf7d56ca1a5b88ad2ca1e995640338f4
+e947d2dfcff418718cdce073fd462fe9cfb3ee8cc00bed16b5184ad70b854bea84f553b09dd67710b4cda45d466d268c99c6659c4cda6e4b2bee27713b4dcd89
+b13719beccdb8b65f9c105b9165cd8f474984377dcdf954f835408b0148d7f90afcbdd64a33b2ae4e4b1fb5c38d768386b8cc3c6cfc756f702455c896ae96ea4
+6a7c18287f36c53d2c3d71f27cc46d59735552a0dce3ff00e06364315c6b84f4f6c366e7c464ada3a56b2fed282cb120eb0d9d51722d8320c39680faca8c3954
+439b54f5f5cb6faecacd8b3ad6d0e35261c1130686088ee816c46f0dcdc0e561b0c2e67dd2f6b02d64ec749f6cd88f5abcb53cbf1dee323b0eb9ca557334b4e4
+7508c138dfe7c19d04d389b84fcf710dc04acb5a3a58a2d606b1c960ac1cb7193de38b9ef5c64f7bc09ef58f0729483ef16caf90e46d38eb94cb563574c2cb37
+3e3c35353460b277967f31f1bcf643522e552f90130eae9e8717630f1c4f4f8f3c8a27934cf2699e5d027998c91b35046cdc833d466f9899d5c5bd1e6c78ee6e
+6ccdec16962069d6043111a00c225b6082f58b6033737a9e609e66e7e49f93c3e2796860362c195608996a63d8e5aab3ecdf81f99a86a0604eb06a09a9d60dac
+5bf5058ad3e3c373b426769b9b3e0c81a75759e75cb036fe9fecd6a0fa089f316c6116f9d819a9a3e3b80cdc30995b180fd0209a83e8d4226a76222d8606dc33
+535e061319a7ffc40029110002020103030402020300000000000000010211031012131420210430314122324051425271ffda0008010301013f01e75b459059
+50b244e58d8e71334d367a56a8f54ed32bd9c58b77c8b11b628b123d5cabc1063f5733abc875990eb6675923ac97f473a7f4473a5f44b2a6bdac3e116596633d
+4cae4458f48c1cbe05e95ff90a38e3f08cb15fb445dd6597d88b2cb3e206577310f4f4f3db224a8911ff0051c6bb6cb2cb2cb225963647cb32ba88c43d22c8cb
+7c2c648fdd595d97a5e965912d9bcdc6247a97f88fb704e9d13f0c9331ce9d0c631f7c74a2a8c534677e6bba24ff002858c6467ba23efbd13132cbbf04511c5c
+96ce94e94dacd8cd8c51313fa322a6331ca98fbdca84c4597645511661f102f48e46998da912499e2bc11b26ace338f56cdcc7214af4cdfa98dfe3aa1310b241
+7d9cd0fece7c7fd8b1d1ff0004e421c91bc721b1f91c46b47a58dd884c421116bec9414be096392d28a1c14858d0e28a4645e0b1fb5626290d9e4de5c5fc95ae
+e1e48af937c5fd925e28716b4a36336338d9c6718e2977d965a290c64e7b476fe48cf69d448ea247348e591c9239246f917236c99c7238cd838b2bb28a1bd18f
+4a286b4dc5eb456946d28a36238cd86c66dd1fbf5a5695a228da5e8fddb2fd9bfe4fffc400271100020201030305010101010000000000000102110312132110
+3031042022415132714061ffda0008010201013f019fa66e64bd3325e9e42f4f33624912c523d2637147ac5f230aa5da9cabc0db63b12ae9815cac92349b66d2
+364d9364a92fb256fc9cf6a4ad92545144f846055024210e5a4d6df81636fcb3f87a5f7631268d2513e59055148911e993f48cac4ccb1b565f6a86254868d232
+ae5d18844910e1d084648e8976e86b8a1e2fc1c4998d7cafa3285d26ab921cab1232c3547b715cf4b387e4c985a22bd9432460953a174cd0d2fb504328aa272e
+192969e0dc1e435a3711ad0e478766376ba648ea4557bd2b1aa20b828aa2465f06456c6868960543e04acc78235726648417831c8dc46ea2528485a0f81a50a0
+38d74c5fd13fe855d19465f234c7063848795b3fd383511ff492ff00d28a170596722b39341b6cd2fecd2726a66b3273e08b6852470596597d71792bd898bd96
+7c075fa3a18d53e95eca349a19a24478628d9b4cdb34234c4f81aa3fa5c054fc15d1f5a1a348e2fa210a3621ad46cc4d98fe9b31fd3661fa6d43f4d981b58cdb
+c68bc68de88f39ba9fd8a66b2d76132cd46a2c691a0aaeb451c0ebaea685919b86b1648fd8e4bebda99e7ad96793495dab351657bafdb7d290e3daa1aff81c7b
+147fffc40037100001020304080305090100000000000001000203112110223132041213203041517105618123339192a134404450627282b1d1e1ffda000801
+0100063f02ca665652b036628de46ab1512478545576b1574ea8b5bf3599164591645902c817bb0aeb648b982a78520713bbf4dc9b96799f24760c97741e7373
+e2b61f4aee3021e75dc737a84e6bacd471f671388544f2a6e39dd0296eed00b263109b337db43c329d10733357a96877533de2d4e866c13371f42811c222d98c
+100534746efed982d0c71bcda70836caad935426cb9a975aee636b9a5399635d3ba6850703c09a701c954ad56299c56b72689a23a53708aa2f72f6508a94766a
+29b44d652a8d280ab82a43430015ec6dd6d6a5ae76ccd55682d73bcd4f665642b2158aaaab66a90fe8a90dc7b291d0dcab064b0540aac0b2a92aacca86c9b5f5
+1c955640561257627c56a8a96a93db3dcc563662b321227d148cfe3b955564c750ba85fed0aeaa8d3eaae43791d94e2682f1e6da2d6911e4f0b5ccbd13a2c8c8
+d2da1b3c9557557184a996482241527c6d4eed2bed7f0615efe21ed08afc43bb4357745d31dfc153c374b3e8a70fc0e2cfd02bbe06ff009d4a1781fab9ca7a4b
+2143fd2caabbae7f7bcbbfb5592af00a9c58a18cef5283585a02019a4ecc04369126eea1722b92e4b95b8aff008aec37395c820775388dd76f4064af35d0fb85
+4703c19852598ac4aa2a99aad15372eae4ab6cc2bb10faabc26ab4536b95ec787457adc38375df70afe49fffc400281000030001030304020301010000000000
+00011121314161105171208191a1b1c1d1e1f030f1ffda0008010100013f21350b67f11946af629e589cb0d081f4acc914840cd036363f53549a37b9ed0742d9
+e0c759bac6d3c1a175adf8e8a2b47c1d90e30dbb3e0ffc01cc35bf88fd043766e9b0d8dfaf4e0e06f8285d2b3f22302d122bd28bb22e479a8adb2119c3656eca
+70bd98d8fa31b2fa9b8a97ab4518af4e93ca159ccfd06f18572c323472e5c2f86216d32946c6fd7745a275309a2a63078105ee6226d8e84ea1f6b086e6658ca9
+fa0084bd6fa6af819836dcc29c2d09794e8f83225facb4fb18a242e8f52b830431e0cc3178301a55a8bff1e56c15aa16bc19a7034b0cb2acaaf96265213a2d0c
+4b69af49b2756a8eee784c5e9a5296a3928c2da0b29b753b2468b979c9f8fd0bbc4f725af495d188b04d56a1ca58b81b35f041265a68e53948ee41cc5b71a255
+884c1806301d12d87f717baee3235941b99fd0584f032aa3a450d6178d847715eb0813685b98bf73f8cc6a19914d9b197535dd94eee4516a84eb44b0ab7b12ec
+79379c8af2dab52376f68d207112435bd1de64a455e4d60909b20f818a310d01f608f7c2217bc93486bf9670508741b16bf186f59291865d19195028b2b1e9ba
+c861b0740b3ca1bf94bb9a7b78b1cdae105ac41b6d77625c15ba34a8636868a3a411455e4677e84dbe45802a1acd72317f627da8e1468ecf71e97eaef23cab16
+a269e4b5ca9cdb007a0978359f935135b27ec5e169921e40370a497217ba29065ca94d6b50e74dec7414cd062fd435ea9e4d29a0ccd42d7036af2356ccbe25c0
+e32cb6fd0423aff8f8124c27a7bc099f426bf7326a2a5b90abadbeed3f826c2e025f26507ee37c96db35eff900ac4d5b1c1d092d887aa38b185387ba1a771ee6
+a9fd1859181558e893171b3a256b125772824cad36ce61a1af8a33dd5ec7f9412fb7d1e1f92aad7e4e27b5185f14a1f7d21e4c1e95271feb22ef8f627956cc57
+b929ca409cb0f28bd4c8f587d0471911453f229334e480d8c6c1c9c80d4744e2860778d0f413e469968124da57b8db6a36211f7583631db23169f03b81c8c98a
+3268522b794f27784bb191289b1da44190c5f5cd121a73237664482920cfa0d2e8347a1c827dce00dc23d6e4904c793429204fa3a15137b928d223c1cb8878c3
+a375a31a87ca141f507eb2b746ba309485d48592040c2d0be9d88431a8c21878e48fffda000c030100020003000000106b0631a8f6e44e0c76cfc6a197c34b9c
+d5a5e0fb54038412dabf4d200da8ebcf66ea2642070e2ecb9e6ef3daaf46c64184b704900e32fd4ed133cd5d6d80b812412cbe809ae198e77100949233ff002e
+20ddb23398c9b0917cb5da4fe182bd6b94f1d5ab100351287c7902e5b68139144dd8dc3e3dfbc7e8199d0098cd819fcd9cdd48ab2249280e782fc18ff9527a96
+29a4292210f465cc52715aba0ea39c6ebfffc400271101010100020202010304030000000000010011213110415161207191f03081b1d1c1e1f1ffda00080103
+01013f104389cc21c966bb8b6ddacdbb30e6e1760317682083f226bd220c3823aa4ead38b03fa0ff009f083a09fabc1fa37d65a708bdc374189867bb39820fcb
+21cfe2c672e5900fd5f13966f2139790bd8fdce02e184441e37f003e0359e01702e4ce9c8e66d0fae3c5cd93bee548c266afa7fcda31820ba967f002116b1cae
+5c401e28190d75f05360df682ce5bdd2504f71e0b2ccdf0d8847cc03a472e6757b9b1c1fa7efff0050b2596db9974cd62e4b9b74dc65294b2db6c30ca1b2cbe4
+4c0b7112c96d89e3708f64e79653ee52cb2cb6db107cc11db166992afecff3f7956a1babebb1ec9336f6adf2571cf4ca665996d806b6c693e2316f81e27b2e27
+df3fbc4610c28c4625c382f62678cfce4c08632d91c7814eed516ba5b08788d679e0e667cc46407d2c3d6ec246905c6cb4d5847521f5690c0507cc07b9c81663
+a10cb0624b3af0294a224575e37773bee79e463d486a41f57c39f8eed92b64cb2db6cbe46123ede5053211d42e98e82dac664b938bb865bb0e522a771236dfe3
+fead3ff1ff0057f2e7c1ab5768ce7af1b16c30afb453e47ac1acd36777399d38253d4bfa95f536c296deaf84c27647cdb21c42ecf036186356bc0bb2b94e4891
+ebc0697dac3259b6885e00f86acbddadf56d90109ee594b2ccccc925996dbe0b3c02d41f3620b84060782ca6d999667c659e062134f231110c2b65999966dfe8
+0c30dbe06186dbffc4002511010101000203000104020300000000000100111031214151a120618191b1d1c1e1f1ffda0008010201013f10eede090f86cde188
+f522993dcc8e692bb9c16df12cf19659270fe33c87715e178409e6daf81ff5c06bb6036d03d36becfb6c7460fbac5870f1965924927058274c709b8fdff88c61
+1f6ba664e8d58599e72cb2c9249b4f3fa20e05fd99fdff00d42110cf08009c5e3fd91b2db780b2cb2c9261f1c26cce08e118897cf01c3727e45e2d9cf32a1e9b
+620820b2cb2c9274842e92463c30bf86e144125a31eac10e2ebbb20fb11041671964969772ed7a45b89642fde18390e9790f00bce9d30701071965925dec2d3d
+465aca26c40fe5261b2e15a0dc9f00b098237252c6218638c25584cb1b133bb116c3a7db6794dff3e61acb6789312be7b00796cbdd9f6f1c09e586e9d41eac5b
+3d65e1e5115347867833a3f69cee5f0caf575accfd96badbcbb82d59d3b0e6c1c3f90fa12f9914a60dd1bc935ead1eecedfb26b5f6bc026df0c2b82a19698585
+90e5919a5911213de98888076cabb603a8b599fd5e4e136193931d3abc7026143f46c91a93c197af01f6ff008ff71ee7f25876fe4b07b3fbbc38fe565ece5384
+895f778982cfc5ea58c2f58bcac1e0bacf51f68fb22d8c3ff75fbbf982f7f9b56afe63af927d30fa9ec43f4dfbad271e999725d8b320d9cee17636a7ee0bdcff
+0036bd4bedc6c6a71df05e9c9d46f623ea2e4c16c84478eb852d8ec6c42a9da7e251df1bfa326ce0642f76271645bc1e235c78b3e43f6268cb4a489c6fe878ce
+05267031c8f1bceb0c360c1271b6f19044ffc400261001000202010305000203000000000000010011213141516171108191a1b1c1e1d1f0f1ffda0008010100
+013f106a73081d65206d754f9e528f6a31aa8a2417d25049ef0c54150cf760d9b8d3da69f12ff497a1083061b1dc4ea2a1babdc7d60e1e5f78b969955bb8c17c
+acb2b91f134fd21e710016fb200a3e385383f69b3f8517b53f647b0a1e84b0b8b059901a9b0b2e817ea0bea420f58a4c3823486dfc8f7b6b65611c16e282ad41
+19940776a3fb25a306218e8a7955411a7ba1a06ca6d7f04d18d4e0852ac688bd07d443d2e7a08075ac0f2d1f8ca2cd46d45cb20eb0ea2cf8c67f89627917bebe
+aa0b187042585d2186a5dc2c05e652c28971c152e3858611136465f4192199999832e0c2b9ac4c7a49ceabfbb950b8fb6287494617479963f2574800f68ac954
+09783a605f03907112399429a85b12565a615e6ce630c36f41a95a8b2e5c194a3a6fd414000f55b9bb0b1981613acc65db29de86f67f83efd16c4a48220950f5
+a554099b5972455371298c1781e18004036730bc1bf55972e5cb9443a7de56098841cc5d79911e91f4e6acc5aa03f90fe7a54a8461182d89b65ce86c88ac30c8
+962710ac42a5729c3e8692e2e22cbf51a0b0ccd9542cadc0c4036cca42a44a7c4bcd6c1b7eae5460cf6b81f16f79c28c18870d546bd23acdccb13307c59ab942
+42df18cb2f1820de29e6186122303dc09c65bc23d6947183282b2f451084e2a5f8a9f76e2b35d6ae38b4d5e04afce655b61ae513f0fd967e5575d453f631516f
+a06e2ba8e405e298e834be60758d11b85310e3710b2a106d48a8b5ce5091017820850bd584af37a038250c9e59596f726ea68f8ce66dd656058a7497b36d406e
+1a45a95728226bd721cc1c415d8a5825059302b022e3761d0387bc52e2594ab58b51531a9530f78d4a08232c15fb91101744b218b788540f7a181e68f50f961b
+bf662621fad162bb827544c5ae521f1a1a5ea092d4a4ea471431d259e9f80cc415172d6a16e501a5973f3a992b7804b4546570a2e2cfd90ce1e4e2c4bf8b9a97
+8aadf785dd6bb12985f45e21535f2c3c08f0c7758f94e2bf12ed50ee131c55d897e308bb1fd4b6fcf65528176bde07af9a5e501ed0ce068e122ceed9f83679d4
+6b0484094ecde6215300ad41d7fac4c933b6781b8a42f217e046258aacdbe496e20bb1b672d388e62054001cda95de51511558e3748665ca8cf4581969ced4f6
+96590f78a6d5792e3fc119468afa89b9d8b648bb674da00d95e0b8ab869188f4bd40fdf68003e61d3460a2b59a8ad18ca6fd930ccbdc7e88a63c95fccb74bc0b
+f6156475c2cd48991b175b90edbadb71e0081581c15742794109341b05e5d07b0cbe97b5ff00b595ed53722e09798bc63f0090b180f6250d08e82cb0643ce670
+d18cb7c29cc23b12b657de39758e344c8103a071e61775a1ff00d599a4401dd36e4ed070095f113350f1275bf92a9156bfe728b1ef7fc41f47b7fa4ca01ef7fe
+100cfe12ff006da75f88970b4ea2fc852a4e88fb86a7466d622be521be5ab98523ac7f0b21648ed4e52a591cd2d805f9964ca8e0b83e503d79966f50dc0bd26f
+a83d1a96885ec6f8978b8b16912248d6b1122abc434dfd095a498e8c54ee864a3bd457b116f09ed2fb21c54ab168350ff12b27cebea53d81051043975300bb51
+999345092c84f28330c75876435eb8260bc01e605c2e183044b8a60458c572e63c13da353dc403097c4a76107883c1021a22e6611790992753c896e44cf5aba9
+1fa216094904236f31846e62ba865704e489c910b51299c4af5348458c449885f246505b4251fc881b090738778210c41070d44bc45e6386483d677263151c16
+41ed0eb10c36466a0ed1259166a013094d88a6c83e210c4b2c6e12e67040992585826462a3ac5a84b89527ffd9}}}}}
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Fusce vitae vestibulum velit. Pellentesque vulputate lectus quis pellentesque commodo. Aliquam erat volutpat. Vestibulum in egestas velit. Pellentesque fermentum nisl vitae fringilla venenatis. Etiam id mauris vitae orci maximus ultricies. Cras fringilla ipsum magna, in fringilla dui commodo a.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Etiam vehicula luctus fermentum. In vel metus congue, pulvinar lectus vel, fermentum dui. Maecenas ante orci, egestas ut aliquet sit amet, sagittis a magna. Aliquam ante quam, pellentesque ut dignissim quis, laoreet eget est. Aliquam erat volutpat. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Ut ullamcorper justo sapien, in cursus libero viverra eget. Vivamus auctor imperdiet urna, at pulvinar leo posuere laoreet. Suspendisse neque nisl, fringilla at iaculis scelerisque, ornare vel dolor. Ut et pulvinar nunc. Pellentesque fringilla mollis efficitur. Nullam venenatis commodo imperdiet. Morbi velit neque, semper quis lorem quis, efficitur dignissim ipsum. Ut ac lorem sed turpis imperdiet eleifend sit amet id sapien.}
+\par \pard\plain \s1\ql\nowidctlpar\hyphpar0\sb240\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs36\alang1081\ab\loch\f4\fs36\lang1033{\listtext\pard\plain }\ilvl0\ls1 \li792\ri0\lin792\rin0\fi-432{\rtlch \ltrch\loch
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Nunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis, vulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus nisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum, ac accumsan nunc vehicula vitae. Nulla eget justo in felis tristique fringilla. Morbi sit amet tortor quis risus auctor condimentum. Morbi in ullamcorper elit. Nulla iaculis tellus sit amet mauris tempus fringilla.}
+\par \pard\plain \s2\ql\nowidctlpar\hyphpar0\sb200\sa120\keepn\ltrpar\cf17\b\dbch\af9\langfe2052\dbch\af13\afs32\alang1081\ab\loch\f4\fs32\lang1033{\listtext\pard\plain }\ilvl1\ls1 \li936\ri0\lin936\rin0\fi-576{\rtlch \ltrch\loch
+Maecenas mauris lectus, lobortis et purus mattis, blandit dictum tellus. }
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Maecenas non lorem quis tellus placerat varius. Nulla facilisi. Aenean congue fringilla justo ut aliquam. Mauris id ex erat. Nunc vulputate neque vitae justo facilisis, non condimentum ante sagittis. Morbi viverra semper lorem nec molestie. Maecenas tincidunt est efficitur ligula euismod, sit amet ornare est vulputate.}
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbxcolumn\shpbxignore\shpleft2819\pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+{\*\flymaincnt5\flyanchor0\flycntnt}{\shp{\*\shpinst\shpwr2\shpwrk3\shpbypara\shpbyignore\shptop0\shpbottom2660\shpbxcolumn\shpbxignore\shpleft2819\shpright6819{\sp{\sn shapeType}{\sv 75}}{\sp{\sn wzDescription}{\sv }}{\sp{\sn wzName}{\sv }}{\sp{\sn pib}{\sv {\pict\picscalex100\picscaley100\piccropl0\piccropr0\piccropt0\piccropb0\picw200\pich133\picwgoal4000\pichgoal2660\jpegblip
+ffd8ffe000104a46494600010101004800480000ffe20c584943435f50524f46494c4500010100000c484c696e6f021000006d6e74725247422058595a2007ce
+00020009000600310000616373704d5346540000000049454320735247420000000000000000000000000000f6d6000100000000d32d48502020000000000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000001163707274000001500000003364657363000001840000
+006c77747074000001f000000014626b707400000204000000147258595a00000218000000146758595a0000022c000000146258595a0000024000000014646d
+6e640000025400000070646d6464000002c400000088767565640000034c0000008676696577000003d4000000246c756d69000003f8000000146d6561730000
+040c0000002474656368000004300000000c725452430000043c0000080c675452430000043c0000080c625452430000043c0000080c7465787400000000436f
+70797269676874202863292031393938204865776c6574742d5061636b61726420436f6d70616e79000064657363000000000000001273524742204945433631
+3936362d322e31000000000000000000000012735247422049454336313936362d322e3100000000000000000000000000000000000000000000000000000000
+0000000000000000000000000000000000000000000058595a20000000000000f35100010000000116cc58595a20000000000000000000000000000000005859
+5a200000000000006fa2000038f50000039058595a2000000000000062990000b785000018da58595a2000000000000024a000000f840000b6cf646573630000
+00000000001649454320687474703a2f2f7777772e6965632e636800000000000000000000001649454320687474703a2f2f7777772e6965632e636800000000
+00000000000000000000000000000000000000000000000000000000000000000000000000000000000064657363000000000000002e4945432036313936362d
+322e312044656661756c742052474220636f6c6f7572207370616365202d207352474200000000000000000000002e4945432036313936362d322e3120446566
+61756c742052474220636f6c6f7572207370616365202d20735247420000000000000000000000000000000000000000000064657363000000000000002c5265
+666572656e63652056696577696e6720436f6e646974696f6e20696e2049454336313936362d322e3100000000000000000000002c5265666572656e63652056
+696577696e6720436f6e646974696f6e20696e2049454336313936362d322e310000000000000000000000000000000000000000000000000000766965770000
+00000013a4fe00145f2e0010cf140003edcc0004130b00035c9e0000000158595a2000000000004c09560050000000571fe76d65617300000000000000010000
+00000000000000000000000000000000028f0000000273696720000000004352542063757276000000000000040000000005000a000f00140019001e00230028
+002d00320037003b00400045004a004f00540059005e00630068006d00720077007c00810086008b00900095009a009f00a400a900ae00b200b700bc00c100c6
+00cb00d000d500db00e000e500eb00f000f600fb01010107010d01130119011f0125012b01320138013e0145014c0152015901600167016e0175017c0183018b
+0192019a01a101a901b101b901c101c901d101d901e101e901f201fa0203020c0214021d0226022f02380241024b0254025d02670271027a0284028e029802a2
+02ac02b602c102cb02d502e002eb02f50300030b03160321032d03380343034f035a03660372037e038a039603a203ae03ba03c703d303e003ec03f904060413
+0420042d043b0448045504630471047e048c049a04a804b604c404d304e104f004fe050d051c052b053a05490558056705770586059605a605b505c505d505e5
+05f6060606160627063706480659066a067b068c069d06af06c006d106e306f507070719072b073d074f076107740786079907ac07bf07d207e507f8080b081f
+08320846085a086e0882089608aa08be08d208e708fb09100925093a094f09640979098f09a409ba09cf09e509fb0a110a270a3d0a540a6a0a810a980aae0ac5
+0adc0af30b0b0b220b390b510b690b800b980bb00bc80be10bf90c120c2a0c430c5c0c750c8e0ca70cc00cd90cf30d0d0d260d400d5a0d740d8e0da90dc30dde
+0df80e130e2e0e490e640e7f0e9b0eb60ed20eee0f090f250f410f5e0f7a0f960fb30fcf0fec1009102610431061107e109b10b910d710f511131131114f116d
+118c11aa11c911e81207122612451264128412a312c312e31303132313431363138313a413c513e5140614271449146a148b14ad14ce14f01512153415561578
+159b15bd15e0160316261649166c168f16b216d616fa171d17411765178917ae17d217f7181b18401865188a18af18d518fa19201945196b199119b719dd1a04
+1a2a1a511a771a9e1ac51aec1b141b3b1b631b8a1bb21bda1c021c2a1c521c7b1ca31ccc1cf51d1e1d471d701d991dc31dec1e161e401e6a1e941ebe1ee91f13
+1f3e1f691f941fbf1fea20152041206c209820c420f0211c2148217521a121ce21fb22272255228222af22dd230a23382366239423c223f0241f244d247c24ab
+24da250925382568259725c725f726272657268726b726e827182749277a27ab27dc280d283f287128a228d429062938296b299d29d02a022a352a682a9b2acf
+2b022b362b692b9d2bd12c052c392c6e2ca22cd72d0c2d412d762dab2de12e162e4c2e822eb72eee2f242f5a2f912fc72ffe3035306c30a430db3112314a3182
+31ba31f2322a3263329b32d4330d3346337f33b833f1342b3465349e34d83513354d358735c235fd3637367236ae36e937243760379c37d738143850388c38c8
+39053942397f39bc39f93a363a743ab23aef3b2d3b6b3baa3be83c273c653ca43ce33d223d613da13de03e203e603ea03ee03f213f613fa23fe24023406440a6
+40e74129416a41ac41ee4230427242b542f7433a437d43c044034447448a44ce45124555459a45de4622466746ab46f04735477b47c04805484b489148d7491d
+496349a949f04a374a7d4ac44b0c4b534b9a4be24c2a4c724cba4d024d4a4d934ddc4e254e6e4eb74f004f494f934fdd5027507150bb51065150519b51e65231
+527c52c75313535f53aa53f65442548f54db5528557555c2560f565c56a956f75744579257e0582f587d58cb591a596959b85a075a565aa65af55b455b955be5
+5c355c865cd65d275d785dc95e1a5e6c5ebd5f0f5f615fb36005605760aa60fc614f61a261f56249629c62f06343639763eb6440649464e9653d659265e7663d
+669266e8673d679367e9683f689668ec6943699a69f16a486a9f6af76b4f6ba76bff6c576caf6d086d606db96e126e6b6ec46f1e6f786fd1702b708670e0713a
+719571f0724b72a67301735d73b87414747074cc7528758575e1763e769b76f8775677b37811786e78cc792a798979e77a467aa57b047b637bc27c217c817ce1
+7d417da17e017e627ec27f237f847fe5804780a8810a816b81cd8230829282f4835783ba841d848084e3854785ab860e867286d7873b879f8804886988ce8933
+899989fe8a648aca8b308b968bfc8c638cca8d318d988dff8e668ece8f368f9e9006906e90d6913f91a89211927a92e3934d93b69420948a94f4955f95c99634
+969f970a977597e0984c98b89924999099fc9a689ad59b429baf9c1c9c899cf79d649dd29e409eae9f1d9f8b9ffaa069a0d8a147a1b6a226a296a306a376a3e6
+a456a4c7a538a5a9a61aa68ba6fda76ea7e0a852a8c4a937a9a9aa1caa8fab02ab75abe9ac5cacd0ad44adb8ae2daea1af16af8bb000b075b0eab160b1d6b24b
+b2c2b338b3aeb425b49cb513b58ab601b679b6f0b768b7e0b859b8d1b94ab9c2ba3bbab5bb2ebba7bc21bc9bbd15bd8fbe0abe84beffbf7abff5c070c0ecc167
+c1e3c25fc2dbc358c3d4c451c4cec54bc5c8c646c6c3c741c7bfc83dc8bcc93ac9b9ca38cab7cb36cbb6cc35ccb5cd35cdb5ce36ceb6cf37cfb8d039d0bad13c
+d1bed23fd2c1d344d3c6d449d4cbd54ed5d1d655d6d8d75cd7e0d864d8e8d96cd9f1da76dafbdb80dc05dc8add10dd96de1cdea2df29dfafe036e0bde144e1cc
+e253e2dbe363e3ebe473e4fce584e60de696e71fe7a9e832e8bce946e9d0ea5beae5eb70ebfbec86ed11ed9cee28eeb4ef40efccf058f0e5f172f1fff28cf319
+f3a7f434f4c2f550f5def66df6fbf78af819f8a8f938f9c7fa57fae7fb77fc07fc98fd29fdbafe4bfedcff6dffffffdb00430005040404040305040404060505
+06080d0808070708100b0c090d131014131210121214171d1914161c1612121a231a1c1e1f212121141924272420261d202120ffdb0043010506060807080f08
+080f201512152020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020ffc2001108008500
+c803011100021101031101ffc4001c0000010501010100000000000000000000020001030405060708ffc4001a01000301010101000000000000000000000102
+0300040506ffda000c03010002100310000001cef90fb09a72952d6a75cbea4d4e63c8fb5e46b47b7ac13eefbbc600d1ab0076056ce0100416acafcf72f764c5
+b2c88cb7775e3dda7264f5754ed3360db03601aa1102d6c81bcfca398411c56ce32d9d4101c847bb834e8bbcea88916beadd3e3db6907534b4989384bd3c6b57
+b1dcfd09846af1ab3660044e582d96ce0b8c977982f6f3d37b5257036b9fabd4fd0f0a4558fa5e5acf3a5d3e2d4eecbbc3604bdbb9a4db0e6118183157c160f8
+ac5d5a347f15a74410725d6263afe6eaf48eef09d7074996aa0a7cea3eb705d52c4e9e4f68e0af54a83b33289472ae558e432c5c3514af8776343ced624d7f9f
+a3d1a27b2b798614fb94983918fcddbe1fd74c0e9e4d74deede75e4647644513222110b640a0d842bc5deb940e0149f97b7dab8d374f1985b3e945d821a0957c
+a376f95fa3c359e5ed1e7f5773006e8e5132b156216c81607917b60d2a294e7185b8dbd53cdbc926255e8bdaf1d6c2a6347c89f57cf3ea72e3db9b690fbf799d
+368cdd95ca265121608e056f34e8b516a62837675ea796fa5e67a4b9fa6ca6f41fa7f91e7bccf5f3d9f03ba16d27c2d5312d0b197b291d645264e9d652ba065a
+6af66b2a894f30e8e9c12dae94b894e8b92d67cef4adcab6267b3f67e7208d2bad7396f84dabbcb61277424f49d5a2d869a74074899200d18311192691ea47a9
+5453a1e6b75ca97edcd61e3ccf93ee36c8e00d09d1b4e85a117571eaa248c8656ab2e55467d044da33a326916bd36d293cf3aef21e9001471538fc3e857d5061
+015379d829436c2eff003e769c84390442da26d20d608ead92cbeb20a9b20c4ac919908ccf21e7fbce529d6557a79bb6bf0d20451dcace54ca995321c81d9308
+b1a28d9aadaef3d1759f31024aee19d779d799ee994ad49e7d274a9372b6996f64be8b7535802719f013a225866d9d73111632309e82d93054684df84e1f55ca
+46c80c88a8b2d8cb315972332915b886da9914b6cfb2c61c63c50cc0be311780b366c74acd95ca8b21e05945d642b3653c0b006d19138d710ce8c810c6224766
+0483163097af9b9971a73d2e08a8b2cb84a01b239078160632d85b44c076b2bae4d9958098f173833572f51cff00ffc400271000020202020005050101000000
+0000000102000304110512101314152120222330310632ffda0008010100010502af2d5695cb5d26526ce5d7af51597af22a09996abbf1aea178c707337fa6eb
+7a0b73ea42f9f6bc2db338bab55388bc6e341c7e34f6fc79edb8f3dab167b56346e130ccf65c5598bc7d78d67e9e62deb429d9dcdcabee7c44eb5b41e0cea82f
+e5f168193fe8ec2389cff5b87fb39bbbf3d66769b982bdb2291aa8c1e19950bb1eea4d76389c2e6fa4cf0db1fa9ce9390b7cce401d0ed17e4f169b71f00c1e0c
+36398c6eb1c7c3ec1e173c65e16e6ff4e5375aafa6c1779b15a24e213f10fa732916d194a68b9da70f9de93391c3afe9cf7d56ca1a5b88ad2ca1e995640338f4
+e947d2dfcff418718cdce073fd462fe9cfb3ee8cc00bed16b5184ad70b854bea84f553b09dd67710b4cda45d466d268c99c6659c4cda6e4b2bee27713b4dcd89
+b13719beccdb8b65f9c105b9165cd8f474984377dcdf954f835408b0148d7f90afcbdd64a33b2ae4e4b1fb5c38d768386b8cc3c6cfc756f702455c896ae96ea4
+6a7c18287f36c53d2c3d71f27cc46d59735552a0dce3ff00e06364315c6b84f4f6c366e7c464ada3a56b2fed282cb120eb0d9d51722d8320c39680faca8c3954
+439b54f5f5cb6faecacd8b3ad6d0e35261c1130686088ee816c46f0dcdc0e561b0c2e67dd2f6b02d64ec749f6cd88f5abcb53cbf1dee323b0eb9ca557334b4e4
+7508c138dfe7c19d04d389b84fcf710dc04acb5a3a58a2d606b1c960ac1cb7193de38b9ef5c64f7bc09ef58f0729483ef16caf90e46d38eb94cb563574c2cb37
+3e3c35353460b277967f31f1bcf643522e552f90130eae9e8717630f1c4f4f8f3c8a27934cf2699e5d027998c91b35046cdc833d466f9899d5c5bd1e6c78ee6e
+6ccdec16962069d6043111a00c225b6082f58b6033737a9e609e66e7e49f93c3e2796860362c195608996a63d8e5aab3ecdf81f99a86a0604eb06a09a9d60dac
+5bf5058ad3e3c373b426769b9b3e0c81a75759e75cb036fe9fecd6a0fa089f316c6116f9d819a9a3e3b80cdc30995b180fd0209a83e8d4226a76222d8606dc33
+535e061319a7ffc40029110002020103030402020300000000000000010211031012131420210430314122324051425271ffda0008010301013f01e75b459059
+50b244e58d8e71334d367a56a8f54ed32bd9c58b77c8b11b628b123d5cabc1063f5733abc875990eb6675923ac97f473a7f4473a5f44b2a6bdac3e116596633d
+4cae4458f48c1cbe05e95ff90a38e3f08cb15fb445dd6597d88b2cb3e206577310f4f4f3db224a8911ff0051c6bb6cb2cb2cb225963647cb32ba88c43d22c8cb
+7c2c648fdd595d97a5e965912d9bcdc6247a97f88fb704e9d13f0c9331ce9d0c631f7c74a2a8c534677e6bba24ff002858c6467ba23efbd13132cbbf04511c5c
+96ce94e94dacd8cd8c51313fa322a6331ca98fbdca84c4597645511661f102f48e46998da912499e2bc11b26ace338f56cdcc7214af4cdfa98dfe3aa1310b241
+7d9cd0fece7c7fd8b1d1ff0004e421c91bc721b1f91c46b47a58dd884c421116bec9414be096392d28a1c14858d0e28a4645e0b1fb5626290d9e4de5c5fc95ae
+e1e48af937c5fd925e28716b4a36336338d9c6718e2977d965a290c64e7b476fe48cf69d448ea247348e591c9239246f917236c99c7238cd838b2bb28a1bd18f
+4a286b4dc5eb456946d28a36238cd86c66dd1fbf5a5695a228da5e8fddb2fd9bfe4fffc400271100020201030305010101010000000000000102110312132110
+3031042022415132714061ffda0008010201013f019fa66e64bd3325e9e42f4f33624912c523d2637147ac5f230aa5da9cabc0db63b12ae9815cac92349b66d2
+364d9364a92fb256fc9cf6a4ad92545144f846055024210e5a4d6df81636fcb3f87a5f7631268d2513e59055148911e993f48cac4ccb1b565f6a86254868d232
+ae5d18844910e1d084648e8976e86b8a1e2fc1c4998d7cafa3285d26ab921cab1232c3547b715cf4b387e4c985a22bd9432460953a174cd0d2fb504328aa272e
+192969e0dc1e435a3711ad0e478766376ba648ea4557bd2b1aa20b828aa2465f06456c6868960543e04acc78235726648417831c8dc46ea2528485a0f81a50a0
+38d74c5fd13fe855d19465f234c7063848795b3fd383511ff492ff00d28a170596722b39341b6cd2fecd2726a66b3273e08b6852470596597d71792bd898bd96
+7c075fa3a18d53e95eca349a19a24478628d9b4cdb34234c4f81aa3fa5c054fc15d1f5a1a348e2fa210a3621ad46cc4d98fe9b31fd3661fa6d43f4d981b58cdb
+c68bc68de88f39ba9fd8a66b2d76132cd46a2c691a0aaeb451c0ebaea685919b86b1648fd8e4bebda99e7ad96793495dab351657bafdb7d290e3daa1aff81c7b
+147fffc40037100001020304080305090100000000000001000203112110223132041213203041517105618123339192a134404450627282b1d1e1ffda000801
+0100063f02ca665652b036628de46ab1512478545576b1574ea8b5bf3599164591645902c817bb0aeb648b982a78520713bbf4dc9b96799f24760c97741e7373
+e2b61f4aee3021e75dc737a84e6bacd471f671388544f2a6e39dd0296eed00b263109b337db43c329d10733357a96877533de2d4e866c13371f42811c222d98c
+100534746efed982d0c71bcda70836caad935426cb9a975aee636b9a5399635d3ba6850703c09a701c954ad56299c56b72689a23a53708aa2f72f6508a94766a
+29b44d652a8d280ab82a43430015ec6dd6d6a5ae76ccd55682d73bcd4f665642b2158aaaab66a90fe8a90dc7b291d0dcab064b0540aac0b2a92aacca86c9b5f5
+1c955640561257627c56a8a96a93db3dcc563662b321227d148cfe3b955564c750ba85fed0aeaa8d3eaae43791d94e2682f1e6da2d6911e4f0b5ccbd13a2c8c8
+d2da1b3c9557557184a996482241527c6d4eed2bed7f0615efe21ed08afc43bb4357745d31dfc153c374b3e8a70fc0e2cfd02bbe06ff009d4a1781fab9ca7a4b
+2143fd2caabbae7f7bcbbfb5592af00a9c58a18cef5283585a02019a4ecc04369126eea1722b92e4b95b8aff008aec37395c820775388dd76f4064af35d0fb85
+4703c19852598ac4aa2a99aad15372eae4ab6cc2bb10faabc26ab4536b95ec787457adc38375df70afe49fffc400281000030001030304020301010000000000
+00011121314161105171208191a1b1c1d1e1f030f1ffda0008010100013f21350b67f11946af629e589cb0d081f4acc914840cd036363f53549a37b9ed0742d9
+e0c759bac6d3c1a175adf8e8a2b47c1d90e30dbb3e0ffc01cc35bf88fd043766e9b0d8dfaf4e0e06f8285d2b3f22302d122bd28bb22e479a8adb2119c3656eca
+70bd98d8fa31b2fa9b8a97ab4518af4e93ca159ccfd06f18572c323472e5c2f86216d32946c6fd7745a275309a2a63078105ee6226d8e84ea1f6b086e6658ca9
+fa0084bd6fa6af819836dcc29c2d09794e8f83225facb4fb18a242e8f52b830431e0cc3178301a55a8bff1e56c15aa16bc19a7034b0cb2acaaf96265213a2d0c
+4b69af49b2756a8eee784c5e9a5296a3928c2da0b29b753b2468b979c9f8fd0bbc4f725af495d188b04d56a1ca58b81b35f041265a68e53948ee41cc5b71a255
+884c1806301d12d87f717baee3235941b99fd0584f032aa3a450d6178d847715eb0813685b98bf73f8cc6a19914d9b197535dd94eee4516a84eb44b0ab7b12ec
+79379c8af2dab52376f68d207112435bd1de64a455e4d60909b20f818a310d01f608f7c2217bc93486bf9670508741b16bf186f59291865d19195028b2b1e9ba
+c861b0740b3ca1bf94bb9a7b78b1cdae105ac41b6d77625c15ba34a8636868a3a411455e4677e84dbe45802a1acd72317f627da8e1468ecf71e97eaef23cab16
+a269e4b5ca9cdb007a0978359f935135b27ec5e169921e40370a497217ba29065ca94d6b50e74dec7414cd062fd435ea9e4d29a0ccd42d7036af2356ccbe25c0
+e32cb6fd0423aff8f8124c27a7bc099f426bf7326a2a5b90abadbeed3f826c2e025f26507ee37c96db35eff900ac4d5b1c1d092d887aa38b185387ba1a771ee6
+a9fd1859181558e893171b3a256b125772824cad36ce61a1af8a33dd5ec7f9412fb7d1e1f92aad7e4e27b5185f14a1f7d21e4c1e95271feb22ef8f627956cc57
+b929ca409cb0f28bd4c8f587d0471911453f229334e480d8c6c1c9c80d4744e2860778d0f413e469968124da57b8db6a36211f7583631db23169f03b81c8c98a
+3268522b794f27784bb191289b1da44190c5f5cd121a73237664482920cfa0d2e8347a1c827dce00dc23d6e4904c793429204fa3a15137b928d223c1cb8878c3
+a375a31a87ca141f507eb2b746ba309485d48592040c2d0be9d88431a8c21878e48fffda000c030100020003000000106b0631a8f6e44e0c76cfc6a197c34b9c
+d5a5e0fb54038412dabf4d200da8ebcf66ea2642070e2ecb9e6ef3daaf46c64184b704900e32fd4ed133cd5d6d80b812412cbe809ae198e77100949233ff002e
+20ddb23398c9b0917cb5da4fe182bd6b94f1d5ab100351287c7902e5b68139144dd8dc3e3dfbc7e8199d0098cd819fcd9cdd48ab2249280e782fc18ff9527a96
+29a4292210f465cc52715aba0ea39c6ebfffc400271101010100020202010304030000000000010011213110415161207191f03081b1d1c1e1f1ffda00080103
+01013f104389cc21c966bb8b6ddacdbb30e6e1760317682083f226bd220c3823aa4ead38b03fa0ff009f083a09fabc1fa37d65a708bdc374189867bb39820fcb
+21cfe2c672e5900fd5f13966f2139790bd8fdce02e184441e37f003e0359e01702e4ce9c8e66d0fae3c5cd93bee548c266afa7fcda31820ba967f002116b1cae
+5c401e28190d75f05360df682ce5bdd2504f71e0b2ccdf0d8847cc03a472e6757b9b1c1fa7efff0050b2596db9974cd62e4b9b74dc65294b2db6c30ca1b2cbe4
+4c0b7112c96d89e3708f64e79653ee52cb2cb6db107cc11db166992afecff3f7956a1babebb1ec9336f6adf2571cf4ca665996d806b6c693e2316f81e27b2e27
+df3fbc4610c28c4625c382f62678cfce4c08632d91c7814eed516ba5b08788d679e0e667cc46407d2c3d6ec246905c6cb4d5847521f5690c0507cc07b9c81663
+a10cb0624b3af0294a224575e37773bee79e463d486a41f57c39f8eed92b64cb2db6cbe46123ede5053211d42e98e82dac664b938bb865bb0e522a771236dfe3
+fead3ff1ff0057f2e7c1ab5768ce7af1b16c30afb453e47ac1acd36777399d38253d4bfa95f536c296deaf84c27647cdb21c42ecf036186356bc0bb2b94e4891
+ebc0697dac3259b6885e00f86acbddadf56d90109ee594b2ccccc925996dbe0b3c02d41f3620b84060782ca6d999667c659e062134f231110c2b65999966dfe8
+0c30dbe06186dbffc4002511010101000203000104020300000000000100111031214151a120618191b1d1c1e1f1ffda0008010201013f10eede090f86cde188
+f522993dcc8e692bb9c16df12cf19659270fe33c87715e178409e6daf81ff5c06bb6036d03d36becfb6c7460fbac5870f1965924927058274c709b8fdff88c61
+1f6ba664e8d58599e72cb2c9249b4f3fa20e05fd99fdff00d42110cf08009c5e3fd91b2db780b2cb2c9261f1c26cce08e118897cf01c3727e45e2d9cf32a1e9b
+620820b2cb2c9274842e92463c30bf86e144125a31eac10e2ebbb20fb11041671964969772ed7a45b89642fde18390e9790f00bce9d30701071965925dec2d3d
+465aca26c40fe5261b2e15a0dc9f00b098237252c6218638c25584cb1b133bb116c3a7db6794dff3e61acb6789312be7b00796cbdd9f6f1c09e586e9d41eac5b
+3d65e1e5115347867833a3f69cee5f0caf575accfd96badbcbb82d59d3b0e6c1c3f90fa12f9914a60dd1bc935ead1eecedfb26b5f6bc026df0c2b82a19698585
+90e5919a5911213de98888076cabb603a8b599fd5e4e136193931d3abc7026143f46c91a93c197af01f6ff008ff71ee7f25876fe4b07b3fbbc38fe565ece5384
+895f778982cfc5ea58c2f58bcac1e0bacf51f68fb22d8c3ff75fbbf982f7f9b56afe63af927d30fa9ec43f4dfbad271e999725d8b320d9cee17636a7ee0bdcff
+0036bd4bedc6c6a71df05e9c9d46f623ea2e4c16c84478eb852d8ec6c42a9da7e251df1bfa326ce0642f76271645bc1e235c78b3e43f6268cb4a489c6fe878ce
+05267031c8f1bceb0c360c1271b6f19044ffc400261001000202010305000203000000000000010011213141516171108191a1b1c1e1d1f0f1ffda0008010100
+013f106a73081d65206d754f9e528f6a31aa8a2417d25049ef0c54150cf760d9b8d3da69f12ff497a1083061b1dc4ea2a1babdc7d60e1e5f78b969955bb8c17c
+acb2b91f134fd21e710016fb200a3e385383f69b3f8517b53f647b0a1e84b0b8b059901a9b0b2e817ea0bea420f58a4c3823486dfc8f7b6b65611c16e282ad41
+19940776a3fb25a306218e8a7955411a7ba1a06ca6d7f04d18d4e0852ac688bd07d443d2e7a08075ac0f2d1f8ca2cd46d45cb20eb0ea2cf8c67f89627917bebe
+aa0b187042585d2186a5dc2c05e652c28971c152e3858611136465f4192199999832e0c2b9ac4c7a49ceabfbb950b8fb6287494617479963f2574800f68ac954
+09783a605f03907112399429a85b12565a615e6ce630c36f41a95a8b2e5c194a3a6fd414000f55b9bb0b1981613acc65db29de86f67f83efd16c4a48220950f5
+a554099b5972455371298c1781e18004036730bc1bf55972e5cb9443a7de56098841cc5d79911e91f4e6acc5aa03f90fe7a54a8461182d89b65ce86c88ac30c8
+962710ac42a5729c3e8692e2e22cbf51a0b0ccd9542cadc0c4036cca42a44a7c4bcd6c1b7eae5460cf6b81f16f79c28c18870d546bd23acdccb13307c59ab942
+42df18cb2f1820de29e6186122303dc09c65bc23d6947183282b2f451084e2a5f8a9f76e2b35d6ae38b4d5e04afce655b61ae513f0fd967e5575d453f631516f
+a06e2ba8e405e298e834be60758d11b85310e3710b2a106d48a8b5ce5091017820850bd584af37a038250c9e59596f726ea68f8ce66dd656058a7497b36d406e
+1a45a95728226bd721cc1c415d8a5825059302b022e3761d0387bc52e2594ab58b51531a9530f78d4a08232c15fb91101744b218b788540f7a181e68f50f961b
+bf662621fad162bb827544c5ae521f1a1a5ea092d4a4ea471431d259e9f80cc415172d6a16e501a5973f3a992b7804b4546570a2e2cfd90ce1e4e2c4bf8b9a97
+8aadf785dd6bb12985f45e21535f2c3c08f0c7758f94e2bf12ed50ee131c55d897e308bb1fd4b6fcf65528176bde07af9a5e501ed0ce068e122ceed9f83679d4
+6b0484094ecde6215300ad41d7fac4c933b6781b8a42f217e046258aacdbe496e20bb1b672d388e62054001cda95de51511558e3748665ca8cf4581969ced4f6
+96590f78a6d5792e3fc119468afa89b9d8b648bb674da00d95e0b8ab869188f4bd40fdf68003e61d3460a2b59a8ad18ca6fd930ccbdc7e88a63c95fccb74bc0b
+f6156475c2cd48991b175b90edbadb71e0081581c15742794109341b05e5d07b0cbe97b5ff00b595ed53722e09798bc63f0090b180f6250d08e82cb0643ce670
+d18cb7c29cc23b12b657de39758e344c8103a071e61775a1ff00d599a4401dd36e4ed070095f113350f1275bf92a9156bfe728b1ef7fc41f47b7fa4ca01ef7fe
+100cfe12ff006da75f88970b4ea2fc852a4e88fb86a7466d622be521be5ab98523ac7f0b21648ed4e52a591cd2d805f9964ca8e0b83e503d79966f50dc0bd26f
+a83d1a96885ec6f8978b8b16912248d6b1122abc434dfd095a498e8c54ee864a3bd457b116f09ed2fb21c54ab168350ff12b27cebea53d81051043975300bb51
+999345092c84f28330c75876435eb8260bc01e605c2e183044b8a60458c572e63c13da353dc403097c4a76107883c1021a22e6611790992753c896e44cf5aba9
+1fa216094904236f31846e62ba865704e489c910b51299c4af5348458c449885f246505b4251fc881b090738778210c41070d44bc45e6386483d677263151c16
+41ed0eb10c36466a0ed1259166a013094d88a6c83e210c4b2c6e12e67040992585826462a3ac5a84b89527ffd9}}}}}
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225\rtlch \ltrch\loch
+
+\par \pard\plain \s51\sl288\slmult1\ql\nowidctlpar\hyphpar0\sb0\sa140\ltrpar\cf17\dbch\af9\langfe2052\dbch\af13\afs24\alang1081\loch\f3\fs24\lang1033\qj\widctlpar\sb0\sa225{\scaps0\caps0\cf1\expnd0\expndtw0\i0\b0\dbch\af12\rtlch \ltrch\loch\fs21\loch\f8\hich\af8
+Nunc ac faucibus odio. Vestibulum neque massa, scelerisque sit amet ligula eu, congue molestie mi. Praesent ut varius sem. Nullam at porttitor arcu, nec lacinia nisi. Ut ac dolor vitae odio interdum condimentum. Vivamus dapibus sodales ex, vitae malesuada ipsum cursus convallis. Maecenas sed egestas nulla, ac condimentum orci. Mauris diam felis, vulputate ac suscipit et, iaculis non est. Curabitur semper arcu ac ligula semper, nec luctus nisl blandit. Integer lacinia ante ac libero lobortis imperdiet. Nullam mollis convallis ipsum, ac accumsan nunc vehicula vitae. }
+\par }
\ No newline at end of file
diff --git a/FileCombiner.Tests/TestFixtures/file-sample_150kB.pdf b/FileCombiner.Tests/TestFixtures/file-sample_150kB.pdf
new file mode 100644
index 0000000..94d9477
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file-sample_150kB.pdf differ
diff --git a/FileCombiner.Tests/TestFixtures/file_example_CSV_50.csv b/FileCombiner.Tests/TestFixtures/file_example_CSV_50.csv
new file mode 100644
index 0000000..5feb5c8
--- /dev/null
+++ b/FileCombiner.Tests/TestFixtures/file_example_CSV_50.csv
@@ -0,0 +1,51 @@
+ο»Ώ0,First Name,Last Name,Gender,Country,Age,Date,Id
+1,Dulce,Abril,Female,United States,32,15/10/2017,1562
+2,Mara,Hashimoto,Female,Great Britain,25,16/08/2016,1582
+3,Philip,Gent,Male,France,36,21/05/2015,2587
+4,Kathleen,Hanner,Female,United States,25,15/10/2017,3549
+5,Nereida,Magwood,Female,United States,58,16/08/2016,2468
+6,Gaston,Brumm,Male,United States,24,21/05/2015,2554
+7,Etta,Hurn,Female,Great Britain,56,15/10/2017,3598
+8,Earlean,Melgar,Female,United States,27,16/08/2016,2456
+9,Vincenza,Weiland,Female,United States,40,21/05/2015,6548
+10,Fallon,Winward,Female,Great Britain,28,16/08/2016,5486
+11,Arcelia,Bouska,Female,Great Britain,39,21/05/2015,1258
+12,Franklyn,Unknow,Male,France,38,15/10/2017,2579
+13,Sherron,Ascencio,Female,Great Britain,32,16/08/2016,3256
+14,Marcel,Zabriskie,Male,Great Britain,26,21/05/2015,2587
+15,Kina,Hazelton,Female,Great Britain,31,16/08/2016,3259
+16,Shavonne,Pia,Female,France,24,21/05/2015,1546
+17,Shavon,Benito,Female,France,39,15/10/2017,3579
+18,Lauralee,Perrine,Female,Great Britain,28,16/08/2016,6597
+19,Loreta,Curren,Female,France,26,21/05/2015,9654
+20,Teresa,Strawn,Female,France,46,21/05/2015,3569
+21,Belinda,Partain,Female,United States,37,15/10/2017,2564
+22,Holly,Eudy,Female,United States,52,16/08/2016,8561
+23,Many,Cuccia,Female,Great Britain,46,21/05/2015,5489
+24,Libbie,Dalby,Female,France,42,21/05/2015,5489
+25,Lester,Prothro,Male,France,21,15/10/2017,6574
+26,Marvel,Hail,Female,Great Britain,28,16/08/2016,5555
+27,Angelyn,Vong,Female,United States,29,21/05/2015,6125
+28,Francesca,Beaudreau,Female,France,23,15/10/2017,5412
+29,Garth,Gangi,Male,United States,41,16/08/2016,3256
+30,Carla,Trumbull,Female,Great Britain,28,21/05/2015,3264
+31,Veta,Muntz,Female,Great Britain,37,15/10/2017,4569
+32,Stasia,Becker,Female,Great Britain,34,16/08/2016,7521
+33,Jona,Grindle,Female,Great Britain,26,21/05/2015,6458
+34,Judie,Claywell,Female,France,35,16/08/2016,7569
+35,Dewitt,Borger,Male,United States,36,21/05/2015,8514
+36,Nena,Hacker,Female,United States,29,15/10/2017,8563
+37,Kelsie,Wachtel,Female,France,27,16/08/2016,8642
+38,Sau,Pfau,Female,United States,25,21/05/2015,9536
+39,Shanice,Mccrystal,Female,United States,36,21/05/2015,2567
+40,Chase,Karner,Male,United States,37,15/10/2017,2154
+41,Tommie,Underdahl,Male,United States,26,16/08/2016,3265
+42,Dorcas,Darity,Female,United States,37,21/05/2015,8765
+43,Angel,Sanor,Male,France,24,15/10/2017,3259
+44,Willodean,Harn,Female,United States,39,16/08/2016,3567
+45,Weston,Martina,Male,United States,26,21/05/2015,6540
+46,Roma,Lafollette,Female,United States,34,15/10/2017,2654
+47,Felisa,Cail,Female,United States,28,16/08/2016,6525
+48,Demetria,Abbey,Female,United States,32,21/05/2015,3265
+49,Jeromy,Danz,Male,United States,39,15/10/2017,3265
+50,Rasheeda,Alkire,Female,United States,29,16/08/2016,6125
diff --git a/FileCombiner.Tests/TestFixtures/file_example_PPTX_250kB.pptx b/FileCombiner.Tests/TestFixtures/file_example_PPTX_250kB.pptx
new file mode 100644
index 0000000..b1191a8
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file_example_PPTX_250kB.pptx differ
diff --git a/FileCombiner.Tests/TestFixtures/file_example_PPT_250kB.ppt b/FileCombiner.Tests/TestFixtures/file_example_PPT_250kB.ppt
new file mode 100644
index 0000000..c825883
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file_example_PPT_250kB.ppt differ
diff --git a/FileCombiner.Tests/TestFixtures/file_example_XLSX_50.xlsx b/FileCombiner.Tests/TestFixtures/file_example_XLSX_50.xlsx
new file mode 100644
index 0000000..246d704
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file_example_XLSX_50.xlsx differ
diff --git a/FileCombiner.Tests/TestFixtures/file_example_XLS_50.xls b/FileCombiner.Tests/TestFixtures/file_example_XLS_50.xls
new file mode 100644
index 0000000..7005d0c
Binary files /dev/null and b/FileCombiner.Tests/TestFixtures/file_example_XLS_50.xls differ
diff --git a/FileCombiner.Tests/Usings.cs b/FileCombiner.Tests/Usings.cs
new file mode 100644
index 0000000..2f6be20
--- /dev/null
+++ b/FileCombiner.Tests/Usings.cs
@@ -0,0 +1,3 @@
+global using Xunit;
+global using FsCheck;
+global using FsCheck.Xunit;
diff --git a/FileCombiner.csproj b/FileCombiner.csproj
index 7904af9..29ed30e 100644
--- a/FileCombiner.csproj
+++ b/FileCombiner.csproj
@@ -11,17 +11,32 @@
true
filecombiner
+
+
+ portable
+ true
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Models/DiscoveredFile.cs b/Models/DiscoveredFile.cs
deleted file mode 100644
index dff0761..0000000
--- a/Models/DiscoveredFile.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using System.Text.Json.Serialization;
-
-namespace FileCombiner.Models;
-
-///
-/// Information about a discovered file
-///
-public record DiscoveredFile(
- string RelativePath,
- string AbsolutePath,
- long Size,
- int Depth
-)
-{
- public string Extension => Path.GetExtension(RelativePath).ToLowerInvariant();
- public string Name => Path.GetFileName(RelativePath);
-}
-
-///
-/// Result of processing operation
-///
-public record ProcessResult(
- List ProcessedFiles,
- List SkippedFiles,
- List SkippedDirectories,
- long TotalSize,
- int TokenCount,
- int FoundDirs
-);
-
-///
-/// Output data structure for JSON export
-///
-public record OutputData(
- [property: JsonPropertyName("summary")] SummaryData Summary,
- [property: JsonPropertyName("files")] List Files
-);
-
-public record SummaryData(
- [property: JsonPropertyName("total_files")] int TotalFiles,
- [property: JsonPropertyName("total_size")] long TotalSize,
- [property: JsonPropertyName("total_tokens")] int totalTokens,
- [property: JsonPropertyName("max_depth")] int MaxDepth
-);
-
-///
-/// Output data for combined files
-///
-public record CombinedFilesData(
- [property: JsonPropertyName("FinalContent")] string FinalContent,
- [property: JsonPropertyName("totalTokens")] int TotalTokens
-);
\ No newline at end of file
diff --git a/Modules/CLI/CommandLineInterface.cs b/Modules/CLI/CommandLineInterface.cs
new file mode 100644
index 0000000..3e650da
--- /dev/null
+++ b/Modules/CLI/CommandLineInterface.cs
@@ -0,0 +1,186 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using CommandLine;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+using Spectre.Console;
+
+namespace FileCombiner.Modules.CLI;
+
+///
+/// Handles CLI help, interactive mode, and output presentation.
+///
+public static class CommandLineInterface
+{
+ ///
+ /// Safely escapes text for Spectre.Console markup to prevent parsing errors.
+ ///
+ /// The text to escape
+ /// Escaped text safe for markup display
+ public static string SafeMarkup(string text)
+ {
+ return Markup.Escape(text);
+ }
+
+ public static CommandLineOptions? Parse(string[] args)
+ {
+ // Use CommandLine library's built-in help generation
+ var parser = new Parser(with => with.HelpWriter = null);
+ var result = parser.ParseArguments(args);
+
+ CommandLineOptions? opts = null;
+ result
+ .WithParsed(o => opts = o)
+ .WithNotParsed(errors =>
+ {
+ // Display help using CommandLine library's built-in formatter
+ var helpText = CommandLine.Text.HelpText.AutoBuild(result, h =>
+ {
+ h.AdditionalNewLineAfterOption = false;
+ h.Heading = "FileCombiner - Combine multiple files into a single reference document";
+ h.Copyright = "";
+ h.AddPreOptionsLine("");
+ h.AddPreOptionsLine("Usage: filecombiner [directory] [options]");
+ h.AddPreOptionsLine("");
+ h.AddPostOptionsLine("");
+ h.AddPostOptionsLine("Examples:");
+ h.AddPostOptionsLine(" filecombiner");
+ h.AddPostOptionsLine(" filecombiner ./src -e .cs,.js -o combined.md");
+ h.AddPostOptionsLine(" filecombiner ./project --exclude \"**/bin/**\" --dry-run");
+ h.MaximumDisplayWidth = 100;
+ return h;
+ }, e => e);
+
+ Console.WriteLine(helpText);
+ });
+
+ return opts;
+ }
+
+ [ExcludeFromCodeCoverage] // Interactive console input - not unit testable
+ public static CommandLineOptions RunInteractive()
+ {
+ try
+ {
+ AnsiConsole.MarkupLine("[bold cyan]π FileCombiner - Interactive Mode[/]");
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[dim]Answer a few questions to configure your file combination...[/]");
+ AnsiConsole.WriteLine();
+
+ // Question 1: Source directory
+ var dir = AnsiConsole.Ask("π [cyan]Source directory to scan[/]:", ".");
+
+ // Question 2: File extensions
+ var exts = AnsiConsole.Ask("π [cyan]File extensions[/] (comma-separated, or * for all):", "*");
+
+ // Question 3: Output destination
+ var outputChoice = AnsiConsole.Prompt(
+ new SelectionPrompt()
+ .Title("πΎ [cyan]Where should the output go?[/]")
+ .AddChoices("Clipboard (default)", "Save to file"));
+
+ string? output = null;
+ if (outputChoice == "Save to file")
+ {
+ output = AnsiConsole.Ask(" [dim]Enter output file path[/]:", "combined.md");
+ }
+
+ // Question 4: Preview mode
+ var dryRun = AnsiConsole.Confirm("π [cyan]Preview mode[/] (show files without processing)?", false);
+
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[green]β[/] Configuration complete!");
+ AnsiConsole.WriteLine();
+
+ return new CommandLineOptions
+ {
+ Directory = dir,
+ OutputFile = string.IsNullOrWhiteSpace(output) ? null : output,
+ Extensions = exts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ MaxDepth = 5, // Use sensible default
+ DryRun = dryRun,
+ Verbose = false, // Use sensible default
+ NoTree = false // Use sensible default
+ };
+ }
+ catch (Exception)
+ {
+ // Fallback to plain console if markup fails
+ Console.WriteLine("FileCombiner - Interactive Mode");
+ Console.WriteLine();
+ Console.WriteLine("Answer a few questions to configure your file combination...");
+ Console.WriteLine();
+
+ Console.Write("Source directory to scan [.]: ");
+ var dir = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(dir)) dir = ".";
+
+ Console.Write("File extensions (comma-separated, or * for all) [*]: ");
+ var exts = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(exts)) exts = "*";
+
+ Console.Write("Output to (1) Clipboard or (2) File? [1]: ");
+ var outputChoice = Console.ReadLine();
+ string? output = null;
+ if (outputChoice == "2")
+ {
+ Console.Write("Enter output file path [combined.md]: ");
+ output = Console.ReadLine();
+ if (string.IsNullOrWhiteSpace(output)) output = "combined.md";
+ }
+
+ Console.Write("Preview mode (show files without processing)? (y/n) [n]: ");
+ var dryRunInput = Console.ReadLine();
+ var dryRun = dryRunInput?.ToLower() == "y";
+
+ Console.WriteLine();
+ Console.WriteLine("Configuration complete!");
+ Console.WriteLine();
+
+ return new CommandLineOptions
+ {
+ Directory = dir,
+ OutputFile = output,
+ Extensions = exts.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
+ MaxDepth = 5,
+ DryRun = dryRun,
+ Verbose = false,
+ NoTree = false
+ };
+ }
+ }
+
+ public static void PrintSummary(ProcessResult result)
+ {
+ AnsiConsole.WriteLine();
+ AnsiConsole.MarkupLine("[bold]Summary:[/]");
+ AnsiConsole.MarkupLine($" π Directories scanned: [cyan]{result.FoundDirs}[/]");
+ AnsiConsole.MarkupLine($" π Files to combine: [yellow]{result.ProcessedFiles.Count}[/]");
+ AnsiConsole.MarkupLine($" π Total size: [cyan]{result.TotalSize:N0}[/] bytes");
+ AnsiConsole.WriteLine();
+ }
+
+ public static async Task OutputResult(CombinedFilesData data, AppConfig config)
+ {
+ if (string.IsNullOrEmpty(data.FinalContent))
+ {
+ AnsiConsole.MarkupLine("[yellow]No content to output[/]");
+ return;
+ }
+
+ if (!string.IsNullOrEmpty(config.OutputFile))
+ try
+ {
+ await File.WriteAllTextAsync(config.OutputFile, data.FinalContent, Encoding.UTF8);
+ AnsiConsole.MarkupLine($"[yellow]πΎ Saved to:[/] {SafeMarkup(config.OutputFile)}");
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLine($"[red]Error saving file:[/] {SafeMarkup(ex.Message)}");
+ AnsiConsole.MarkupLine("[yellow]π Copying to clipboard instead...[/]");
+ await RunTimeUtils.CopyToClipboard(data);
+ }
+ else
+ await RunTimeUtils.CopyToClipboard(data);
+ }
+}
\ No newline at end of file
diff --git a/Modules/CLI/CommandLineOptions.cs b/Modules/CLI/CommandLineOptions.cs
new file mode 100644
index 0000000..ef02e5c
--- /dev/null
+++ b/Modules/CLI/CommandLineOptions.cs
@@ -0,0 +1,57 @@
+using CommandLine;
+using JetBrains.Annotations;
+
+namespace FileCombiner.Modules.CLI;
+
+///
+/// Command line options using CommandLineParser library (no defaults here).
+/// Defaults live in AppConfig.CreateDefault (SSOT).
+///
+[Verb("combine", true, HelpText = "Combine files from a directory into a single reference document")]
+[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
+public class CommandLineOptions
+{
+ [Value(0, Required = false, HelpText = "Source directory to scan (default: current directory)")]
+ public string Directory { get; set; } = ".";
+
+ [Option('o', "output", HelpText = "Output file path (default: copy to clipboard)")]
+ public string? OutputFile { get; set; }
+
+ [Option('e', "extensions", Separator = ',',
+ HelpText = "File extensions to include (comma-separated, use '*' for auto-detect)")]
+ public IEnumerable? Extensions { get; set; }
+
+ [Option('r', "max-depth", HelpText = "Maximum directory depth to recurse (default: 5)")]
+ public int? MaxDepth { get; set; }
+
+ [Option("exclude", Separator = ',', HelpText = "Exclude files/dirs matching glob patterns")]
+ public IEnumerable? ExcludePatterns { get; set; }
+
+ [Option("dry-run", HelpText = "Preview files without processing")]
+ public bool DryRun { get; set; }
+
+ [Option('v', "verbose", HelpText = "Enable detailed logging output")]
+ public bool Verbose { get; set; }
+
+ [Option('i', "interactive", HelpText = "Enter interactive mode with prompts")]
+ public bool Interactive { get; set; }
+
+ // Kept for backward compatibility but not exposed in help
+ [Option("include", Separator = ',', Hidden = true)]
+ public IEnumerable? IncludePatterns { get; set; }
+
+ [Option("ignore-dirs", Separator = ',', Hidden = true)]
+ public IEnumerable? IgnoreDirs { get; set; }
+
+ [Option('c', "compact", Hidden = true)]
+ public bool CompactMode { get; set; }
+
+ [Option("no-tree", Hidden = true)]
+ public bool NoTree { get; set; }
+
+ [Option("max-files", Hidden = true)]
+ public int? MaxFiles { get; set; }
+
+ [Option("max-file-size", Hidden = true)]
+ public long? MaxFileSize { get; set; }
+}
\ No newline at end of file
diff --git a/Modules/Configuration/AppConfig.cs b/Modules/Configuration/AppConfig.cs
new file mode 100644
index 0000000..6a5c0b8
--- /dev/null
+++ b/Modules/Configuration/AppConfig.cs
@@ -0,0 +1,96 @@
+using FileCombiner.Modules.CLI;
+using JetBrains.Annotations;
+
+namespace FileCombiner.Modules.Configuration;
+
+///
+/// Application configuration - single source of truth for defaults.
+///
+[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
+public class AppConfig : ConfigObject
+{
+ public string Directory { get; private init; } = string.Empty;
+ public string? OutputFile { get; set; }
+ public List Extensions { get; set; } = ["*"];
+ public int MaxDepth { get; set; } = 5;
+ public bool CompactMode { get; private set; }
+ public bool IncludeTree { get; private set; } = true;
+ public List ExcludePatterns { get; set; } = [];
+ public List IncludePatterns { get; set; } = [];
+ public HashSet IgnoreDirs { get; private set; } = [];
+ public HashSet IgnoreFiles { get; private init; } = [];
+ public long MaxFileSize { get; set; } = 10 * 1024 * 1024; // 10MB
+ public int MaxTotalFiles { get; set; } = 1000;
+ public bool DryRun { get; set; }
+ public bool Verbose { get; set; }
+
+ // exactly one element equal to "*"
+ public bool AutoDetectText => Extensions is ["*"];
+
+ public static AppConfig CreateDefault(string directory)
+ {
+ return new AppConfig
+ {
+ Directory = directory,
+ IgnoreDirs =
+ [
+ "__pycache__", ".git", ".svn", ".hg", ".bzr", "_darcs",
+ "node_modules", ".venv", "venv", "env", ".env",
+ "build", "dist", ".tox", ".pytest_cache", ".mypy_cache",
+ "target", "bin", "obj", ".vs", ".vscode", ".idea",
+ "coverage", ".coverage", "htmlcov", ".nyc_output"
+ ],
+ IgnoreFiles = [".DS_Store", "Thumbs.db", "desktop.ini", ".gitkeep"]
+ };
+ }
+
+ public static AppConfig FromCommandLine(CommandLineOptions opts)
+ {
+ var c = CreateDefault(opts.Directory);
+
+ c.OutputFile = UseIfProvided(c.OutputFile, opts.OutputFile);
+ c.Extensions = UseIfProvided(c.Extensions, opts.Extensions?.ToList());
+ c.MaxDepth = UseIfProvided(c.MaxDepth, opts.MaxDepth);
+ c.IncludePatterns = UseIfProvided(c.IncludePatterns, opts.IncludePatterns?.ToList());
+ c.ExcludePatterns = UseIfProvided(c.ExcludePatterns, opts.ExcludePatterns?.ToList());
+ c.IgnoreDirs = UnionIfProvided(c.IgnoreDirs, opts.IgnoreDirs);
+ c.MaxTotalFiles = UseIfProvided(c.MaxTotalFiles, opts.MaxFiles);
+ c.MaxFileSize = UseIfProvided(c.MaxFileSize, opts.MaxFileSize);
+
+ c.CompactMode = opts.CompactMode;
+ c.IncludeTree = !opts.NoTree;
+ c.DryRun = opts.DryRun;
+ c.Verbose = opts.Verbose;
+
+ c.Extensions = NormalizeExt(c.Extensions);
+ return c;
+ }
+
+ private static List NormalizeExt(List exts)
+ {
+ // If wildcard is present *alone*, keep it as-is.
+ if (exts is ["*"]) return exts;
+
+ var outList = new List(exts.Count);
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
+ foreach (var raw in exts)
+ {
+ var e = raw.Trim();
+ if (string.IsNullOrEmpty(e)) continue;
+ if (e == "*")
+ {
+ outList.Clear();
+ outList.Add("*");
+ return outList;
+ } // treat lone * as special
+
+ if (!e.StartsWith('.')) e = "." + e;
+ if (seen.Add(e)) outList.Add(e);
+ }
+
+ // if ended up empty, fall back to "*"
+ return outList.Count > 0 ? outList : ["*"];
+ }
+}
\ No newline at end of file
diff --git a/Modules/Configuration/ConfigObject.cs b/Modules/Configuration/ConfigObject.cs
new file mode 100644
index 0000000..d64b56e
--- /dev/null
+++ b/Modules/Configuration/ConfigObject.cs
@@ -0,0 +1,60 @@
+using System.Collections;
+
+namespace FileCombiner.Modules.Configuration;
+
+///
+/// Base configuration object providing helpers for merging overrides into defaults.
+///
+public abstract class ConfigObject
+{
+ ///
+ /// Use source if provided; otherwise keep current (value types).
+ ///
+ protected static T UseIfProvided(T current, T? source) where T : struct
+ {
+ return source ?? current;
+ }
+
+ ///
+ /// Use source if provided; for strings ignore null/whitespace; for collections require non-empty; else keep current.
+ ///
+ protected static T UseIfProvided(T current, T? source) where T : class?
+ {
+ return source switch
+ {
+ null => current,
+ string s => string.IsNullOrWhiteSpace(s) ? current : source,
+ IEnumerable e => HasAny(e) ? source : current,
+ _ => source
+ };
+ }
+
+ ///
+ /// Merge source into current HashSet if source has any items; always returns current.
+ ///
+ protected static HashSet UnionIfProvided(HashSet current, IEnumerable? source)
+ {
+ if (source is null)
+ return current;
+
+ var items = source as ICollection ?? source.ToArray();
+ if (items.Count == 0)
+ return current;
+
+ current.UnionWith(items);
+ return current;
+ }
+
+ private static bool HasAny(IEnumerable e)
+ {
+ var en = e.GetEnumerator();
+ try
+ {
+ return en.MoveNext();
+ }
+ finally
+ {
+ (en as IDisposable)?.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Models/CombinedFilesData.cs b/Modules/Models/CombinedFilesData.cs
new file mode 100644
index 0000000..58ffc3d
--- /dev/null
+++ b/Modules/Models/CombinedFilesData.cs
@@ -0,0 +1,14 @@
+// ReSharper disable NotAccessedPositionalProperty.Global
+using System.Text.Json.Serialization;
+
+namespace FileCombiner.Modules.Models;
+
+///
+/// Output data for combined files
+///
+public record CombinedFilesData(
+ [property: JsonPropertyName("FinalContent")]
+ string FinalContent,
+ [property: JsonPropertyName("totalTokens")]
+ int TotalTokens
+);
\ No newline at end of file
diff --git a/Modules/Models/DiscoveredFile.cs b/Modules/Models/DiscoveredFile.cs
new file mode 100644
index 0000000..86f18a7
--- /dev/null
+++ b/Modules/Models/DiscoveredFile.cs
@@ -0,0 +1,16 @@
+// ReSharper disable NotAccessedPositionalProperty.Global
+namespace FileCombiner.Modules.Models;
+
+///
+/// Information about a discovered file
+///
+public record DiscoveredFile(
+ string RelativePath,
+ string AbsolutePath,
+ long Size,
+ int Depth
+)
+{
+ public string Extension => Path.GetExtension(RelativePath).ToLowerInvariant();
+ public string Name => Path.GetFileName(RelativePath);
+}
\ No newline at end of file
diff --git a/Modules/Models/ProcessResult.cs b/Modules/Models/ProcessResult.cs
new file mode 100644
index 0000000..0c2d269
--- /dev/null
+++ b/Modules/Models/ProcessResult.cs
@@ -0,0 +1,14 @@
+// ReSharper disable NotAccessedPositionalProperty.Global
+namespace FileCombiner.Modules.Models;
+
+///
+/// Result of processing operation
+///
+public record ProcessResult(
+ List ProcessedFiles,
+ List SkippedFiles,
+ List SkippedDirectories,
+ long TotalSize,
+ int TokenCount,
+ int FoundDirs
+);
\ No newline at end of file
diff --git a/Modules/RunTimeUtils.cs b/Modules/RunTimeUtils.cs
new file mode 100644
index 0000000..54e7368
--- /dev/null
+++ b/Modules/RunTimeUtils.cs
@@ -0,0 +1,130 @@
+using System.Text;
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+using FileCombiner.Modules.Services;
+using FileCombiner.Modules.Services.TextExtractors;
+using FileCombiner.Modules.Services.TextExtractors.FileTypes;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+using TextCopy;
+
+namespace FileCombiner.Modules;
+
+///
+/// Runtime utility helpers for DI configuration and environment I/O.
+///
+public static class RunTimeUtils
+{
+ public static void ConfigureServices(IServiceCollection services, CommandLineOptions options)
+ {
+ services.AddLogging(builder =>
+ {
+ builder.AddConsole();
+ builder.SetMinimumLevel(options.Verbose ? LogLevel.Debug : LogLevel.Information);
+ });
+
+ // register all leaf extractors first (these are the specialized extractors)
+ var leafExtractors = new List
+ {
+ new DocxTextExtractor(),
+ new ExcelTextExtractor(),
+ new CsvTextExtractor(),
+ new PdfTextExtractor(),
+ new HtmlToMarkdownExtractor(),
+ new PptxTextExtractor(),
+ new YamlTextExtractor(),
+ new MarkdownTextExtractor()
+ };
+
+ // register factory with the leaf extractors (avoiding circular dependency)
+ var factory = new ContentExtractorFactory(leafExtractors);
+ services.AddSingleton(factory);
+
+ // register the main FileContentExtractor that uses the factory
+ var mainExtractor = new FileContentExtractor(factory);
+ services.AddSingleton(mainExtractor);
+
+ // other services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ public static void AddTextParser(IServiceCollection services, AppConfig config)
+ {
+ services.AddSingleton(_ => new FileSystemTextGlobber(config.IncludePatterns, config.ExcludePatterns));
+ }
+
+ public static async Task CopyToClipboard(CombinedFilesData data)
+ {
+ try
+ {
+ await ClipboardService.SetTextAsync(data.FinalContent);
+ AnsiConsole.MarkupLine(
+ $"π Output [yellow]copied[/] to clipboard ([magenta]{data.TotalTokens:N0}[/] tokens)");
+ }
+ catch (Exception ex)
+ {
+ var tempFile = Path.Combine(Path.GetTempPath(),
+ $"combined-files-{DateTime.Now:yyyyMMdd-HHmmss}.md");
+ await File.WriteAllTextAsync(tempFile, data.FinalContent, Encoding.UTF8);
+
+ AnsiConsole.MarkupLine($"[yellow]β οΈ Clipboard failed, saved to:[/] {tempFile}");
+ AnsiConsole.MarkupLine($"[dim]Error: {ex.Message}[/]");
+ }
+ }
+
+ public static string BuildDirectoryTree(IEnumerable files, AppConfig config)
+ {
+ var root = new DirectoryNode(".");
+ foreach (var file in files)
+ {
+ var relPath = file.RelativePath.Replace('\\', '/');
+ var parts = relPath.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ var current = root;
+
+ for (var depth = 0; depth < parts.Length - 1 && depth < config.MaxDepth; depth++)
+ {
+ var part = parts[depth];
+ if (!current.Children.TryGetValue(part, out var child))
+ {
+ child = new DirectoryNode(part);
+ current.Children[part] = child;
+ }
+
+ current = child;
+ }
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("## Directory Structure\n");
+ RenderNode(sb, root, 0, config.MaxDepth);
+ sb.AppendLine("\n" + new string('-', 79) + "\n");
+ return sb.ToString();
+ }
+
+ private static void RenderNode(StringBuilder sb, DirectoryNode node, int indent, int maxDepth)
+ {
+ if (indent > 0)
+ sb.AppendLine($"{new string(' ', (indent - 1) * 2)}- `{node.Name}`");
+
+ if (indent >= maxDepth) return;
+
+ foreach (var child in node.Children.Values.OrderBy(c => c.Name))
+ RenderNode(sb, child, indent + 1, maxDepth);
+ }
+
+ private sealed class DirectoryNode
+ {
+ public DirectoryNode(string name)
+ {
+ Name = name;
+ }
+
+ public string Name { get; }
+ public Dictionary Children { get; } = new(StringComparer.OrdinalIgnoreCase);
+ }
+}
\ No newline at end of file
diff --git a/Services/IFileCombinerService.cs b/Modules/Services/IFileCombinerService.cs
similarity index 57%
rename from Services/IFileCombinerService.cs
rename to Modules/Services/IFileCombinerService.cs
index 9cc4b8c..11d1f94 100644
--- a/Services/IFileCombinerService.cs
+++ b/Modules/Services/IFileCombinerService.cs
@@ -1,12 +1,15 @@
+// ReSharper disable UnusedType.Global
using System.Text;
-using FileCombiner.Configuration;
-using FileCombiner.Models;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+using FileCombiner.Modules.Services.TextExtractors;
using Microsoft.Extensions.Logging;
+namespace FileCombiner.Modules.Services;
-namespace FileCombiner.Services;
///
-/// Main service orchestrating the file combination process - Facade Pattern
+/// Main service orchestrating the file combination process - Facade Pattern.
+/// Responsible for orchestrating discovery, extraction, and final document assembly.
///
public interface IFileCombinerService
{
@@ -14,102 +17,52 @@ public interface IFileCombinerService
}
///
-/// Implementation of the main file combination logic
+/// Implementation of the main file combination logic.
+/// Delegates extraction to IFileTextExtractor implementations and tree building to RunTimeUtils.
///
public class FileCombinerService : IFileCombinerService
{
- private readonly IFileDiscoveryService _discoveryService;
+ private static readonly char[] TokenSeps = [' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?'];
private readonly ILanguageDetectionService _languageService;
private readonly ILogger _logger;
- private static readonly char[] TokenSeps = [' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?'];
+ private readonly IFileTextExtractor _textExtractor;
- // TODO: Extract Presentation Logic to Dedicated Class
- public FileCombinerService(
- IFileDiscoveryService discoveryService,
- ILanguageDetectionService languageService,
- ILogger logger)
+ public FileCombinerService(ILanguageDetectionService languageService,
+ ILogger logger,
+ IFileTextExtractor textExtractor)
{
- _discoveryService = discoveryService;
_languageService = languageService;
_logger = logger;
+ _textExtractor = textExtractor;
}
public async Task CombineFilesAsync(AppConfig config, ProcessResult result)
{
- // Discovery phase
-
- if (!result.ProcessedFiles.Any())
- {
-
+ if (result.ProcessedFiles.Count == 0)
return new CombinedFilesData(string.Empty, 0);
- }
- // Build content
var output = new StringBuilder();
- // Header
+ // Document header
output.AppendLine("## Combined Files Reference");
output.AppendLine();
output.AppendLine("This is a combined file representative of a folder structure, only used as reference.");
output.AppendLine();
- // Directory tree
+ // Directory tree section
if (config.IncludeTree)
- {
- BuildDirectoryTree(output, result.ProcessedFiles);
- }
+ output.Append(RunTimeUtils.BuildDirectoryTree(result.ProcessedFiles, config));
- // Process files
+ // Per-file extraction and inclusion
foreach (var fileInfo in result.ProcessedFiles)
- {
- // Use plain text format instead of markup
-
await ProcessFile(output, fileInfo, config);
- }
var finalContent = output.ToString();
-
- // Simple token estimation
var tokenCount = EstimateTokens(finalContent);
return new CombinedFilesData(finalContent, tokenCount);
}
- private static void BuildDirectoryTree(StringBuilder output, List files)
- {
- output.AppendLine("## Directory Structure\n");
-
- var dirs = files
- .Select(f => Path.GetDirectoryName(f.RelativePath) ?? string.Empty)
- .SelectMany(dir => ExpandParents(dir))
- .Where(d => d.Length > 0)
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .OrderBy(d => d)
- .ToList();
-
- foreach (var d in dirs)
- output.AppendLine($"- `{d.Replace('\\','/')}`");
-
- output.AppendLine("\n" + new string('-', 79) + "\n");
- }
-
- private static IEnumerable ExpandParents(string dir)
- {
- var parts = dir.Replace('\\','/').Split('/', StringSplitOptions.RemoveEmptyEntries);
- if (parts.Length == 0) yield break;
-
- var current = parts[0];
- yield return current;
-
- for (int i = 1; i < parts.Length; i++)
- {
- current += "/" + parts[i];
- yield return current;
- }
- }
-
-
-
private async Task ProcessFile(StringBuilder output, DiscoveredFile discoveredFile, AppConfig config)
{
try
@@ -117,12 +70,12 @@ private async Task ProcessFile(StringBuilder output, DiscoveredFile discoveredFi
// File header
output.AppendLine($"### `{discoveredFile.RelativePath}`");
- // Language detection
+ // Syntax highlighting language guess
var language = _languageService.DetectLanguage(discoveredFile.RelativePath);
output.AppendLine($"```{language}");
- // Read and process content
- var content = await File.ReadAllTextAsync(discoveredFile.AbsolutePath);
+ // Extract text content using unified extractor
+ var content = await _textExtractor.ExtractTextAsync(discoveredFile.AbsolutePath);
if (string.IsNullOrWhiteSpace(content))
{
@@ -130,8 +83,8 @@ private async Task ProcessFile(StringBuilder output, DiscoveredFile discoveredFi
}
else
{
- var processedContent = ProcessContent(content, config);
- output.AppendLine(processedContent);
+ var processed = ProcessContent(content, config);
+ output.AppendLine(processed);
}
output.AppendLine("```");
@@ -151,7 +104,6 @@ private static string ProcessContent(string content, AppConfig config)
if (!config.CompactMode)
return content;
- // Simple compact mode - remove copyright headers and excessive whitespace
var lines = content.Split('\n');
var result = new List();
var inCopyright = false;
@@ -160,7 +112,6 @@ private static string ProcessContent(string content, AppConfig config)
{
var trimmed = line.Trim();
- // Skip copyright blocks
if (trimmed.Contains("copyright", StringComparison.OrdinalIgnoreCase) && trimmed.StartsWith('#'))
{
inCopyright = true;
@@ -168,13 +119,10 @@ private static string ProcessContent(string content, AppConfig config)
}
if (inCopyright && (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith('#')))
- {
continue;
- }
inCopyright = false;
- // Skip excessive blank lines
if (string.IsNullOrEmpty(trimmed) && result.LastOrDefault() == string.Empty)
continue;
@@ -186,7 +134,6 @@ private static string ProcessContent(string content, AppConfig config)
private static int EstimateTokens(string text)
{
- // Simple estimation: split by whitespace and punctuation
return text.Split(TokenSeps, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length;
}
}
\ No newline at end of file
diff --git a/Modules/Services/IFileDiscoveryService.cs b/Modules/Services/IFileDiscoveryService.cs
new file mode 100644
index 0000000..89f8795
--- /dev/null
+++ b/Modules/Services/IFileDiscoveryService.cs
@@ -0,0 +1,241 @@
+// ReSharper disable UnusedType.Global
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Models;
+using Microsoft.Extensions.Logging;
+using Spectre.Console;
+
+namespace FileCombiner.Modules.Services;
+
+///
+/// Service for discovering files in directory structure with verbose logging and Spectre markup.
+///
+public interface IFileDiscoveryService
+{
+ Task DiscoverFilesAsync(AppConfig config);
+}
+
+public class FileDiscoveryService : IFileDiscoveryService
+{
+ private readonly ITextDetectionService _textDetectionService;
+ private readonly ITextMatcherService _matcher;
+ private readonly ILogger _logger;
+
+ public FileDiscoveryService(ITextDetectionService textDetectionService, ITextMatcherService matcher, ILogger logger)
+ {
+ _textDetectionService = textDetectionService;
+ _matcher = matcher;
+ _logger = logger;
+ }
+
+ public async Task DiscoverFilesAsync(AppConfig config)
+ {
+ _logger.LogDebug("DiscoverFilesAsync: Method entry point reached");
+
+ var foundFiles = new List();
+ var skippedFiles = new List();
+ var skippedDirs = new List();
+
+ _logger.LogDebug("DiscoverFilesAsync: Initialized collections");
+
+ AnsiConsole.MarkupLine($"π Scanning [cyan]{Esc(config.Directory)}[/] (max depth: {config.MaxDepth})...");
+ _logger.LogInformation("Starting directory scan in {Directory} with depth={Depth}", config.Directory, config.MaxDepth);
+ _logger.LogDebug("DiscoverFilesAsync: About to call ScanDirectory");
+
+ var startTime = DateTime.UtcNow;
+
+ var foundDirs = await ScanDirectory(new DirectoryInfo(config.Directory), config, foundFiles, skippedFiles, skippedDirs, 0)
+ .ConfigureAwait(false);
+
+ _logger.LogDebug("DiscoverFilesAsync: ScanDirectory completed, processing results");
+
+ var totalSize = foundFiles.Sum(f => f.Size);
+ var folderCount = foundFiles.Select(f => Path.GetDirectoryName(f.RelativePath)).Distinct().Count();
+
+ var elapsed = DateTime.UtcNow - startTime;
+ _logger.LogInformation("Discovery complete: {Files} files, {Dirs} dirs, {Elapsed} ms",
+ foundFiles.Count, folderCount, elapsed.TotalMilliseconds);
+
+ _logger.LogDebug("DiscoverFilesAsync: Calculated statistics - totalSize={Size}, folderCount={Count}", totalSize, folderCount);
+
+ AnsiConsole.MarkupLine($"β
Found [green]{folderCount}[/] folders and [cyan]{foundFiles.Count}[/] files in [dim]{elapsed.TotalMilliseconds:N0} ms[/].");
+
+ // --- print summary results ---
+ if (foundFiles.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No files found to process.[/]");
+ return new ProcessResult(foundFiles, skippedFiles, skippedDirs, totalSize, 0, foundDirs);
+ }
+
+ if (config.Verbose)
+ {
+ var list = foundFiles.Count <= 20
+ ? foundFiles
+ : foundFiles.Take(10).ToList();
+
+ foreach (var (file, i) in list.Select((f, i) => (f, i)))
+ {
+ var sizeInfo = file.Size > 0 ? $" [dim]({file.Size:N0} bytes)[/]" : "";
+ AnsiConsole.MarkupLine($" [green]{i + 1}:[/] [grey]{Esc(file.RelativePath)}[/]{sizeInfo}");
+ }
+
+ if (foundFiles.Count > 20)
+ AnsiConsole.MarkupLine($" [dim]... and {foundFiles.Count - 10} more files[/]");
+ }
+
+ if (skippedDirs.Count > 0)
+ {
+ AnsiConsole.MarkupLine($"π Skipped [red]{skippedDirs.Count}[/] directories");
+ _logger.LogDebug("Skipped {Count} directories", skippedDirs.Count);
+ if (config.Verbose)
+ foreach (var dir in skippedDirs.Take(5))
+ _logger.LogTrace(" SkippedDir={Dir}", dir);
+ }
+
+ if (skippedFiles.Count > 0)
+ {
+ AnsiConsole.MarkupLine($"π Skipped [red]{skippedFiles.Count}[/] files");
+ _logger.LogDebug("Skipped {Count} files", skippedFiles.Count);
+ if (config.Verbose)
+ foreach (var file in skippedFiles.Take(5))
+ _logger.LogTrace(" SkippedFile={File}", file);
+ }
+
+ return new ProcessResult(foundFiles, skippedFiles, skippedDirs, totalSize, 0, foundDirs);
+ }
+
+ private async Task ScanDirectory(
+ DirectoryInfo directory,
+ AppConfig config,
+ List foundFiles,
+ List skippedFiles,
+ List skippedDirs,
+ int currentDepth)
+ {
+ if (currentDepth > config.MaxDepth)
+ {
+ _logger.LogDebug("Skipping {Dir} (depth {Depth} > {Max})", directory.FullName, currentDepth, config.MaxDepth);
+ return 0;
+ }
+
+ var opts = new EnumerationOptions { RecurseSubdirectories = false, IgnoreInaccessible = true };
+ var subdirCount = 0;
+
+ try
+ {
+ // files
+ foreach (var file in directory.EnumerateFiles("*", opts))
+ {
+ if (foundFiles.Count >= config.MaxTotalFiles)
+ {
+ _logger.LogWarning("Reached file limit ({Limit}), stopping.", config.MaxTotalFiles);
+ break;
+ }
+
+ var relativePath = Path.GetRelativePath(config.Directory, file.FullName);
+ var fi = new DiscoveredFile(relativePath, file.FullName, file.Length, currentDepth);
+
+ var (skip, reason) = await ShouldSkipFileAsync(fi, config).ConfigureAwait(false);
+ if (skip)
+ {
+ skippedFiles.Add($"{relativePath} {reason}");
+ if (config.Verbose)
+ _logger.LogTrace("Skipped file {File}: {Reason}", relativePath, reason);
+ continue;
+ }
+
+ foundFiles.Add(fi);
+ if (config.Verbose && foundFiles.Count % 25 == 0)
+ _logger.LogInformation("Discovered {Count} files so far...", foundFiles.Count);
+ }
+
+ // directories
+ foreach (var subDir in directory.EnumerateDirectories("*", opts))
+ {
+ if (foundFiles.Count >= config.MaxTotalFiles) break;
+
+ var (skip, reason) = ShouldSkipDirectory(subDir, config, currentDepth + 1);
+ if (skip)
+ {
+ var rel = Path.GetRelativePath(config.Directory, subDir.FullName);
+ skippedDirs.Add($"{Esc(rel)} {reason}");
+ if (config.Verbose)
+ _logger.LogTrace("Skipped directory {Dir}: {Reason}", rel, reason);
+ continue;
+ }
+
+ subdirCount += 1;
+ subdirCount += await ScanDirectory(subDir, config, foundFiles, skippedFiles, skippedDirs, currentDepth + 1)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ var rel = Path.GetRelativePath(config.Directory, directory.FullName);
+ skippedDirs.Add($"{Esc(rel)} | [red]access denied[/]");
+ _logger.LogWarning("Access denied to {Dir}", directory.FullName);
+ }
+ catch (Exception ex)
+ {
+ var rel = Path.GetRelativePath(config.Directory, directory.FullName);
+ skippedDirs.Add($"{Esc(rel)} | [red]error:[/] {Markup.Escape(ex.Message)}");
+ _logger.LogError(ex, "Error scanning directory {Dir}", directory.FullName);
+ }
+
+ return subdirCount;
+ }
+
+ private (bool ShouldSkip, string Reason) ShouldSkipDirectory(DirectoryInfo dir, AppConfig config, int depth)
+ {
+ if (depth > config.MaxDepth)
+ return (true, $"| exceeds max depth ([yellow]{config.MaxDepth}[/])");
+
+ if (config.IgnoreDirs.Contains(dir.Name))
+ return (true, "| in ignore list");
+
+ var rel = Path.GetRelativePath(config.Directory, dir.FullName);
+ var match = _matcher.IsMatch(rel, true);
+ if (!match)
+ _logger.LogTrace("Directory {Dir} excluded by matcher", rel);
+
+ return !match ? (true, "| excluded by glob pattern") : (false, string.Empty);
+ }
+
+ private async Task<(bool ShouldSkip, string Reason)> ShouldSkipFileAsync(DiscoveredFile fileInfo, AppConfig config)
+ {
+ if (fileInfo.Size > config.MaxFileSize)
+ return (true, $"| too large ([yellow]{fileInfo.Size:N0}[/] bytes)");
+
+ if (config.IgnoreFiles.Contains(fileInfo.Name))
+ return (true, "| in ignore list");
+
+ if (!string.IsNullOrEmpty(config.OutputFile))
+ {
+ var outputPath = Path.GetFullPath(config.OutputFile);
+ if (fileInfo.AbsolutePath.Equals(outputPath, StringComparison.OrdinalIgnoreCase))
+ return (true, "| is output file");
+ }
+
+ if (config.AutoDetectText)
+ {
+ var (isText, encoding) = await _textDetectionService.IsTextFileAsync(fileInfo.AbsolutePath).ConfigureAwait(false);
+ if (!isText)
+ {
+ _logger.LogTrace("File {File} skipped (binary: {Encoding})", fileInfo.RelativePath, encoding);
+ return (true, $" | not a text file ([yellow]{encoding}[/])");
+ }
+ }
+ else if (!config.Extensions.Contains(fileInfo.Extension))
+ {
+ _logger.LogTrace("File {File} skipped (extension {Ext} not included)", fileInfo.RelativePath, fileInfo.Extension);
+ return (true, $"| extension not included ([yellow]{fileInfo.Extension}[/])");
+ }
+
+ var match = _matcher.IsMatch(fileInfo.RelativePath);
+ if (!match)
+ _logger.LogTrace("File {File} excluded by matcher", fileInfo.RelativePath);
+
+ return !match ? (true, "| excluded by glob patterns") : (false, string.Empty);
+ }
+
+ private static string Esc(string s) => Markup.Escape(s);
+}
diff --git a/Modules/Services/ILanguageDetectionService.cs b/Modules/Services/ILanguageDetectionService.cs
new file mode 100644
index 0000000..212ee0e
--- /dev/null
+++ b/Modules/Services/ILanguageDetectionService.cs
@@ -0,0 +1,59 @@
+namespace FileCombiner.Modules.Services;
+
+///
+/// Service for detecting programming or document languages for syntax highlighting.
+///
+public interface ILanguageDetectionService
+{
+ string DetectLanguage(string filename);
+}
+
+///
+/// Implementation using file extensions and known filename patterns.
+///
+// ReSharper disable once UnusedType.Global
+public class LanguageDetectionService : ILanguageDetectionService
+{
+ private static readonly Dictionary ExtensionMap = new(StringComparer.OrdinalIgnoreCase)
+ {
+ // Code and scripting languages
+ { ".py", "python" }, { ".js", "javascript" }, { ".ts", "typescript" },
+ { ".jsx", "javascript" }, { ".tsx", "typescript" }, { ".java", "java" },
+ { ".c", "c" }, { ".cpp", "cpp" }, { ".cc", "cpp" }, { ".cxx", "cpp" },
+ { ".cs", "csharp" }, { ".php", "php" }, { ".rb", "ruby" }, { ".go", "go" },
+ { ".rs", "rust" }, { ".kt", "kotlin" }, { ".swift", "swift" }, { ".scala", "scala" },
+ { ".sh", "bash" }, { ".sql", "sql" }, { ".r", "r" }, { ".m", "matlab" },
+ { ".pl", "perl" }, { ".lua", "lua" }, { ".html", "html" }, { ".htm", "html" },
+ { ".xml", "xml" }, { ".css", "css" }, { ".scss", "scss" },
+ { ".json", "json" }, { ".yaml", "yaml" }, { ".yml", "yaml" },
+ { ".toml", "toml" }, { ".md", "markdown" }, { ".rst", "rst" },
+ { ".tex", "latex" }, { ".dockerfile", "dockerfile" },
+ { ".makefile", "makefile" }, { ".ini", "ini" }, { ".cfg", "ini" },
+ { ".conf", "ini" },
+
+ // Document and structured text formats
+ { ".docx", "text" }, { ".pptx", "text" }, { ".xlsx", "text" },
+ { ".pdf", "text" }, { ".csv", "text" }, { ".rtf", "text" },
+ { ".log", "text" }
+ };
+
+ private static readonly Dictionary FilenameMap = new(StringComparer.OrdinalIgnoreCase)
+ {
+ { "dockerfile", "dockerfile" }, { "makefile", "makefile" }, { "rakefile", "ruby" },
+ { "gemfile", "ruby" }, { "requirements.txt", "text" },
+ { ".gitignore", "text" }, { ".env", "bash" },
+ { ".bashrc", "bash" }, { ".zshrc", "bash" },
+ { "package.json", "json" }, { "tsconfig.json", "json" },
+ { "README", "markdown" }, { "LICENSE", "text" }
+ };
+
+ public string DetectLanguage(string filename)
+ {
+ var name = Path.GetFileName(filename);
+ if (FilenameMap.TryGetValue(name, out var lang))
+ return lang;
+
+ var ext = Path.GetExtension(filename);
+ return ExtensionMap.TryGetValue(ext, out lang) ? lang : "text";
+ }
+}
\ No newline at end of file
diff --git a/Services/ITextDetectionService.cs b/Modules/Services/ITextDetectionService.cs
similarity index 87%
rename from Services/ITextDetectionService.cs
rename to Modules/Services/ITextDetectionService.cs
index 277f817..f926fea 100644
--- a/Services/ITextDetectionService.cs
+++ b/Modules/Services/ITextDetectionService.cs
@@ -1,9 +1,10 @@
using System.Text;
+// ReSharper disable UnusedType.Global
-namespace FileCombiner.Services;
+namespace FileCombiner.Modules.Services;
///
-/// Service for detecting if files contain readable text - Interface Segregation Principle
+/// Service for detecting if files contain readable text - Interface Segregation Principle
///
public interface ITextDetectionService
{
@@ -11,7 +12,7 @@ public interface ITextDetectionService
}
///
-/// Implementation of text detection using multiple heuristics
+/// Implementation of text detection using multiple heuristics
///
public class TextDetectionService : ITextDetectionService
{
@@ -40,7 +41,6 @@ public class TextDetectionService : ITextDetectionService
var encodings = new[] { "utf-8", "utf-16", "latin-1", "cp1252" };
foreach (var encodingName in encodings)
- {
try
{
var encoding = Encoding.GetEncoding(encodingName);
@@ -54,9 +54,8 @@ public class TextDetectionService : ITextDetectionService
}
catch (Exception)
{
- continue;
+ // ignored
}
- }
return (false, "unknown");
}
diff --git a/Services/ITextMatcherService.cs b/Modules/Services/ITextMatcherService.cs
similarity index 64%
rename from Services/ITextMatcherService.cs
rename to Modules/Services/ITextMatcherService.cs
index 883c211..a9c5a88 100644
--- a/Services/ITextMatcherService.cs
+++ b/Modules/Services/ITextMatcherService.cs
@@ -1,49 +1,50 @@
using Microsoft.Extensions.FileSystemGlobbing;
-namespace FileCombiner.Services;
+namespace FileCombiner.Modules.Services;
public interface ITextMatcherService
{
bool IsMatch(string relativePath, bool isDirectory = false);
}
-
public sealed class FileSystemTextGlobber : ITextMatcherService
{
- private readonly Matcher _include;
private readonly Matcher _exclude;
+ private readonly Matcher _include;
public FileSystemTextGlobber(IEnumerable? includePatterns, IEnumerable? excludePatterns)
{
_include = new Matcher(StringComparison.OrdinalIgnoreCase);
- if (includePatterns != null && includePatterns.Any())
- _include.AddIncludePatterns(includePatterns);
+ var includes = includePatterns?.ToArray() ?? [];
+ if (includes.Length > 0)
+ _include.AddIncludePatterns(includes);
else
_include.AddInclude("**/*");
_exclude = new Matcher(StringComparison.OrdinalIgnoreCase);
- if (excludePatterns != null && excludePatterns.Any())
- _exclude.AddExcludePatterns(excludePatterns);
+ var excludes = excludePatterns?.ToArray() ?? [];
+ if (excludes.Length > 0)
+ _exclude.AddExcludePatterns(excludes);
}
public bool IsMatch(string relativePath, bool isDirectory = false)
{
var p = Normalize(relativePath, isDirectory);
- var inc = _include.Match(new[] { p }).HasMatches;
+ var inc = _include.Match([p]).HasMatches;
if (!inc) return false;
- var exc = _exclude.Match(new[] { p }).HasMatches;
+ var exc = _exclude.Match([p]).HasMatches;
return !exc;
}
- public static string Normalize(string path, bool isDirectory)
+ private static string Normalize(string path, bool isDirectory)
{
// unify separators, strip leading ./ or /
var s = path.Replace('\\', '/');
if (s.StartsWith("./", StringComparison.Ordinal)) s = s[2..];
- else if (s.StartsWith("/", StringComparison.Ordinal)) s = s[1..];
+ else if (s.StartsWith('/')) s = s[1..];
// For dir patterns like "**/bin/**" some matchers behave better with a trailing slash
- if (isDirectory && !s.EndsWith("/", StringComparison.Ordinal)) s += "/";
+ if (isDirectory && !s.EndsWith('/')) s += "/";
return s;
}
diff --git a/Modules/Services/TextExtractors/ContentExtractorFactory.cs b/Modules/Services/TextExtractors/ContentExtractorFactory.cs
new file mode 100644
index 0000000..480a982
--- /dev/null
+++ b/Modules/Services/TextExtractors/ContentExtractorFactory.cs
@@ -0,0 +1,16 @@
+namespace FileCombiner.Modules.Services.TextExtractors;
+
+public class ContentExtractorFactory
+{
+ private readonly List _extractors;
+
+ public ContentExtractorFactory(IEnumerable extractors)
+ {
+ _extractors = extractors.ToList();
+ }
+
+ public IFileTextExtractor? GetExtractor(string extension)
+ {
+ return _extractors.FirstOrDefault(e => e.CanHandle(extension));
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileContentExtractor.cs b/Modules/Services/TextExtractors/FileContentExtractor.cs
new file mode 100644
index 0000000..d04d40a
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileContentExtractor.cs
@@ -0,0 +1,37 @@
+namespace FileCombiner.Modules.Services.TextExtractors;
+
+///
+/// The unified high-level extractor that delegates to specialized ones via the factory.
+///
+public class FileContentExtractor : IFileTextExtractor
+{
+ private readonly ContentExtractorFactory _factory;
+
+ public FileContentExtractor(ContentExtractorFactory factory)
+ {
+ _factory = factory;
+ }
+
+ public bool CanHandle(string extension)
+ {
+ return true;
+ // acts as universal fallback
+ }
+
+ public async Task ExtractTextAsync(string filePath)
+ {
+ var ext = Path.GetExtension(filePath);
+ var extractor = _factory.GetExtractor(ext);
+
+ if (extractor != null && extractor != this) return await extractor.ExtractTextAsync(filePath);
+
+ try
+ {
+ return await File.ReadAllTextAsync(filePath);
+ }
+ catch (Exception ex)
+ {
+ return $"# Error reading file: {ex.Message}";
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/CsvTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/CsvTextExtractor.cs
new file mode 100644
index 0000000..2bf2b44
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/CsvTextExtractor.cs
@@ -0,0 +1,15 @@
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class CsvTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string extension)
+ {
+ return extension.Equals(".csv", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ var lines = await File.ReadAllLinesAsync(path);
+ return string.Join(Environment.NewLine, lines);
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/DocxTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/DocxTextExtractor.cs
new file mode 100644
index 0000000..659c849
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/DocxTextExtractor.cs
@@ -0,0 +1,31 @@
+using System.Text;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Wordprocessing;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class DocxTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string extension)
+ {
+ return string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ return await Task.Run(() =>
+ {
+ var sb = new StringBuilder();
+
+ using var doc = WordprocessingDocument.Open(path, false);
+ var mainPart = doc.MainDocumentPart;
+ if (mainPart?.Document.Body is null)
+ return string.Empty;
+
+ foreach (var paragraph in mainPart.Document.Body.Descendants())
+ sb.AppendLine(paragraph.InnerText);
+
+ return sb.ToString();
+ });
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/ExcelTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/ExcelTextExtractor.cs
new file mode 100644
index 0000000..cd65b55
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/ExcelTextExtractor.cs
@@ -0,0 +1,34 @@
+using System.Text;
+using ClosedXML.Excel;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class ExcelTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string extension)
+ {
+ return extension is ".xlsx" or ".xls";
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ return await Task.Run(() =>
+ {
+ var sb = new StringBuilder();
+ using var workbook = new XLWorkbook(path);
+ foreach (var ws in workbook.Worksheets)
+ {
+ sb.AppendLine($"# Sheet: {ws.Name}");
+ foreach (var row in ws.RowsUsed())
+ {
+ var values = row.CellsUsed().Select(c => c.GetValue());
+ sb.AppendLine(string.Join("\t", values));
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ });
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/HtmlTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/HtmlTextExtractor.cs
new file mode 100644
index 0000000..2a9d710
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/HtmlTextExtractor.cs
@@ -0,0 +1,18 @@
+using ReverseMarkdown;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class HtmlToMarkdownExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string ext)
+ {
+ return ext is ".html" or ".htm";
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ var html = await File.ReadAllTextAsync(path);
+ var converter = new Converter(new Config { GithubFlavored = true });
+ return converter.Convert(html);
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/MarkdownTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/MarkdownTextExtractor.cs
new file mode 100644
index 0000000..3cbe969
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/MarkdownTextExtractor.cs
@@ -0,0 +1,14 @@
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class MarkdownTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string ext)
+ {
+ return ext.Equals(".md", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ return await File.ReadAllTextAsync(path); // raw passthrough
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/PdfTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/PdfTextExtractor.cs
new file mode 100644
index 0000000..efef50b
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/PdfTextExtractor.cs
@@ -0,0 +1,36 @@
+using System.Text;
+using UglyToad.PdfPig;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class PdfTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string extension)
+ {
+ return extension.Equals(".pdf", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ return await Task.Run(() =>
+ {
+ try
+ {
+ var sb = new StringBuilder();
+ using var pdf = PdfDocument.Open(path);
+ foreach (var page in pdf.GetPages())
+ {
+ sb.AppendLine($"# Page {page.Number}");
+ sb.AppendLine(page.Text);
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+ catch (Exception ex)
+ {
+ return $"# Error reading PDF: {ex.Message}";
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/PptxTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/PptxTextExtractor.cs
new file mode 100644
index 0000000..d8b26e5
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/PptxTextExtractor.cs
@@ -0,0 +1,34 @@
+using System.Text;
+using DocumentFormat.OpenXml.Drawing;
+using DocumentFormat.OpenXml.Packaging;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class PptxTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string ext)
+ {
+ return ext.Equals(".pptx", StringComparison.OrdinalIgnoreCase);
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ return await Task.Run(() =>
+ {
+ var sb = new StringBuilder();
+ using var pres = PresentationDocument.Open(path, false);
+ var slides = pres.PresentationPart?.SlideParts;
+ if (slides == null) return sb.ToString();
+ foreach (var slide in slides)
+ {
+ var text = slide.Slide.Descendants()
+ .Select(t => t.Text)
+ .Where(s => !string.IsNullOrWhiteSpace(s));
+ sb.AppendLine(string.Join(Environment.NewLine, text));
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ });
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/FileTypes/YamlTextExtractor.cs b/Modules/Services/TextExtractors/FileTypes/YamlTextExtractor.cs
new file mode 100644
index 0000000..44167bb
--- /dev/null
+++ b/Modules/Services/TextExtractors/FileTypes/YamlTextExtractor.cs
@@ -0,0 +1,41 @@
+using System.Text;
+using YamlDotNet.RepresentationModel;
+
+namespace FileCombiner.Modules.Services.TextExtractors.FileTypes;
+
+public class YamlTextExtractor : IFileTextExtractor
+{
+ public bool CanHandle(string ext)
+ {
+ return ext is ".yaml" or ".yml";
+ }
+
+ public async Task ExtractTextAsync(string path)
+ {
+ try
+ {
+ var text = await File.ReadAllTextAsync(path);
+ var yaml = new YamlStream();
+ yaml.Load(new StringReader(text));
+
+ var sb = new StringBuilder();
+ for (var i = 0; i < yaml.Documents.Count; i++)
+ {
+ // build a temp stream containing only one document
+ var single = new YamlStream(yaml.Documents[i]);
+ await using var writer = new StringWriter();
+ single.Save(writer, false);
+
+ sb.AppendLine(writer.ToString().TrimEnd());
+ if (i < yaml.Documents.Count - 1)
+ sb.AppendLine("\n---\n");
+ }
+
+ return sb.ToString();
+ }
+ catch
+ {
+ return await File.ReadAllTextAsync(path);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Modules/Services/TextExtractors/IFileTextExtractor.cs b/Modules/Services/TextExtractors/IFileTextExtractor.cs
new file mode 100644
index 0000000..f28a48d
--- /dev/null
+++ b/Modules/Services/TextExtractors/IFileTextExtractor.cs
@@ -0,0 +1,10 @@
+namespace FileCombiner.Modules.Services.TextExtractors;
+
+///
+/// Interface implemented by all format-specific extractors (and the top-level orchestrator).
+///
+public interface IFileTextExtractor
+{
+ bool CanHandle(string extension);
+ Task ExtractTextAsync(string path);
+}
\ No newline at end of file
diff --git a/Program.cs b/Program.cs
index e155439..9d84631 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,37 +1,59 @@
-ο»Ώusing System.Text;
-using CommandLine;
-using FileCombiner.CLI;
-using FileCombiner.Configuration;
-using FileCombiner.Models;
-using FileCombiner.Services;
+ο»Ώusing System.Diagnostics.CodeAnalysis;
+using System.Text;
+using FileCombiner.Modules;
+using FileCombiner.Modules.CLI;
+using FileCombiner.Modules.Configuration;
+using FileCombiner.Modules.Services;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
using Spectre.Console;
namespace FileCombiner;
///
-/// Main program entry point with dependency injection setup
+/// Main program entry point with dependency injection and CLI orchestration.
///
-class Program
+// ReSharper disable once ClassNeverInstantiated.Global
+[ExcludeFromCodeCoverage] // Entry point - not unit testable
+internal class Program
{
- static string Esc(string s) => Markup.Escape(s);
+ private static string Esc(string s)
+ {
+ return Markup.Escape(s);
+ }
+
+ ///
+ /// Wraps an async operation with timeout detection to diagnose hangs.
+ ///
+ private static async Task WithTimeoutDetection(Task task, string operationName, int timeoutSeconds = 30)
+ {
+ var timeoutTask = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds));
+ var completedTask = await Task.WhenAny(task, timeoutTask);
+
+ if (completedTask == timeoutTask)
+ {
+ AnsiConsole.MarkupLine($"[red]WARNING:[/] Operation '{operationName}' has been running for {timeoutSeconds} seconds...");
+ AnsiConsole.MarkupLine("[yellow]This may indicate a deadlock or hang. Waiting for completion...[/]");
+ return await task; // Continue waiting but user is informed
+ }
+
+ return await task;
+ }
- static async Task Main(string[] args)
+ private static async Task Main(string[] args)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Console.OutputEncoding = Encoding.UTF8;
- // Parse command line arguments
- var parseResult = Parser.Default.ParseArguments(args);
- return await parseResult.MapResult(
- async options => await RunApplication(options),
- _ => Task.FromResult(1)
- );
- }
+ // Parse command line options
+ var options = CommandLineInterface.Parse(args);
+ if (options == null) return 1;
+
+ // If interactive mode is requested, run interactive prompts
+ if (options.Interactive)
+ {
+ options = CommandLineInterface.RunInteractive();
+ }
- private static async Task RunApplication(CommandLineOptions options)
- {
try
{
// Validate directory
@@ -41,43 +63,92 @@ private static async Task RunApplication(CommandLineOptions options)
return 1;
}
+ // Build app configuration from CLI
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Building app configuration from command line options...[/]");
+
+ var config = AppConfig.FromCommandLine(options);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: App configuration created successfully[/]");
+
// Setup dependency injection
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Setting up dependency injection container...[/]");
+
var services = new ServiceCollection();
- ConfigureServices(services, options);
- // Create configuration
- var config = AppConfig.FromCommandLine(options);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Configuring services...[/]");
+
+ RunTimeUtils.ConfigureServices(services, options);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Adding text parser services...[/]");
+
+ RunTimeUtils.AddTextParser(services, config);
- // Add dependent Service
- AddTextParser(services, config);
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Building service provider...[/]");
+ else
+ AnsiConsole.MarkupLine("[grey]Building service provider...[/]");
+
+ await using var provider = services.BuildServiceProvider();
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Service provider built successfully[/]");
+ else
+ AnsiConsole.MarkupLine("[grey]Service provider built.[/]");
- // Build Service Provider
- await using var serviceProvider = services.BuildServiceProvider();
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Resolving IFileDiscoveryService...[/]");
+
+ var discoveryService = await Task.Run(() => provider.GetRequiredService());
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: IFileDiscoveryService resolved successfully[/]");
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Resolving IFileCombinerService...[/]");
+
+ var combinerService = await Task.Run(() => provider.GetRequiredService());
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: IFileCombinerService resolved successfully[/]");
+ else
+ AnsiConsole.MarkupLine("[grey]Services resolved successfully.[/]");
- // Get services
- var discoveryService = serviceProvider.GetRequiredService();
- var combinerService = serviceProvider.GetRequiredService();
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Starting file discovery...[/]");
+ else
+ AnsiConsole.MarkupLine("[grey]Starting discovery...[/]");
- // STEP 1: Discover files first - show what we found
+ // STEP 1: Discover files
AnsiConsole.MarkupLine("[bold]Discovering files...[/]");
- var discoveryResult = await discoveryService.DiscoverFilesAsync(config);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Calling DiscoverFilesAsync...[/]");
+
+ var discoveryResult = await WithTimeoutDetection(
+ discoveryService.DiscoverFilesAsync(config),
+ "File Discovery",
+ 30
+ ).ConfigureAwait(false);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: DiscoverFilesAsync completed successfully[/]");
+ else
+ AnsiConsole.MarkupLine("[grey]Finished discovery...[/]");
+
if (discoveryResult.ProcessedFiles.Count == 0)
{
AnsiConsole.MarkupLine("[yellow]No files found to process.[/]");
return 0;
}
- else
- {
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine($"[bold]Summary:[/]");
- AnsiConsole.MarkupLine($" π Directories scanned: [cyan]{discoveryResult.FoundDirs}[/]");
- AnsiConsole.MarkupLine($" π Files to combine: [yellow]{discoveryResult.ProcessedFiles.Count}[/]");
- AnsiConsole.MarkupLine($" π Total size: [cyan]{discoveryResult.TotalSize:N0}[/] bytes");
- AnsiConsole.WriteLine();
- }
-
-
+ // STEP 2: Print summary
+ CommandLineInterface.PrintSummary(discoveryResult);
if (config.DryRun)
{
@@ -85,98 +156,40 @@ private static async Task RunApplication(CommandLineOptions options)
return 0;
}
- // STEP 2: Now that user can see what files were found, ask for confirmation
- if (!AnsiConsole.Confirm($"Proceed with combining these {discoveryResult.ProcessedFiles.Count} files?"))
+ // Confirm action
+ if (!await AnsiConsole.ConfirmAsync($"Proceed with combining these {discoveryResult.ProcessedFiles.Count} files?"))
{
AnsiConsole.MarkupLine("[yellow]Operation cancelled[/]");
return 0;
}
+
AnsiConsole.WriteLine();
- // STEP 3: User said yes, now actually combine the files
+
+ // STEP 3: Combine files
AnsiConsole.MarkupLine("[bold]Combining files...[/]");
- var result = await combinerService.CombineFilesAsync(config, discoveryResult);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: Calling CombineFilesAsync...[/]");
+
+ var combined = await WithTimeoutDetection(
+ combinerService.CombineFilesAsync(config, discoveryResult),
+ "File Combining",
+ 60
+ ).ConfigureAwait(false);
+
+ if (options.Verbose)
+ AnsiConsole.MarkupLine("[dim]DEBUG: CombineFilesAsync completed successfully[/]");
- // STEP 4: Output result
- await OutputResult(result, config);
+ // STEP 4: Output result (delegated to CLI)
+ await CommandLineInterface.OutputResult(combined, config);
return 0;
}
catch (Exception ex)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Esc(ex.Message)}");
- if (options.Verbose)
- {
- AnsiConsole.WriteException(ex);
- }
+ AnsiConsole.WriteException(ex, ExceptionFormats.ShortenPaths | ExceptionFormats.ShortenTypes);
return 1;
}
}
-
- private static void ConfigureServices(ServiceCollection services, CommandLineOptions options)
- {
- // Logging
- services.AddLogging(builder =>
- {
- builder.AddConsole();
- builder.SetMinimumLevel(options.Verbose ? LogLevel.Debug : LogLevel.Information);
- });
-
- // Application services - Dependency Injection pattern
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- services.AddTransient();
- }
-
- private static void AddTextParser(ServiceCollection services, AppConfig config)
- {
- services.AddSingleton(_ = new FileSystemTextGlobber(config.IncludePatterns, config.ExcludePatterns));
- }
-
- private static async Task OutputResult(CombinedFilesData data, AppConfig config)
- {
- if (string.IsNullOrEmpty(data.FinalContent))
- {
- AnsiConsole.MarkupLine("[yellow]No content to output[/]");
- return;
- }
-
- if (!string.IsNullOrEmpty(config.OutputFile))
- {
- try
- {
- await File.WriteAllTextAsync(config.OutputFile, data.FinalContent, Encoding.UTF8);
- AnsiConsole.MarkupLine($"[yellow]πΎ Saved to:[/] {config.OutputFile}");
- }
- catch (Exception ex)
- {
- AnsiConsole.MarkupLine($"[red]Error saving file:[/] {Esc(ex.Message)}");
- AnsiConsole.MarkupLine("[yellow]π Copying to clipboard instead...[/]");
- await CopyToClipboard(data);
- }
- }
- else
- {
- await CopyToClipboard(data);
- }
- }
-
- private static async Task CopyToClipboard(CombinedFilesData data)
- {
- try
- {
-
- await TextCopy.ClipboardService.SetTextAsync(data.FinalContent);
- AnsiConsole.MarkupLine($"π Output [yellow]copied[/] to clipboard ([magenta]{data.TotalTokens:N0}[/] Tokens)");
- }
- catch (Exception ex)
- {
- // Fallback to temp file like before
- var tempFile = Path.Combine(Path.GetTempPath(), $"combined-files-{DateTime.Now:yyyyMMdd-HHmmss}.md");
- await File.WriteAllTextAsync(tempFile, data.FinalContent, Encoding.UTF8);
-
- AnsiConsole.MarkupLine($"[yellow]β οΈ Clipboard failed, saved to:[/] {tempFile}");
- AnsiConsole.MarkupLine($"[dim]Error: {ex.Message}[/]");
- }
- }
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9087d50..bc62f7b 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# FileCombiner
+[](https://github.com/Kydoimos97/FileCombiner/actions/workflows/ci.yml)
+[](https://codecov.io/gh/Kydoimos97/FileCombiner)
+[](https://opensource.org/licenses/MIT)
+[](https://dotnet.microsoft.com/download)
+
A high-performance .NET CLI tool that combines multiple files from a directory structure into a single reference document. Perfect for code reviews, documentation, or sharing project snapshots with AI assistants.
## Features
@@ -36,8 +41,11 @@ dotnet publish -c Release -r win-x64 --self-contained
### Basic Usage
```bash
-# Combine all text files in current directory
-filecombiner .
+# Combine all text files in current directory (uses sensible defaults)
+filecombiner
+
+# Combine files from a specific directory
+filecombiner ./src
# Combine specific file types
filecombiner ./src -e .cs,.js,.ts
@@ -47,19 +55,19 @@ filecombiner ./project -o combined-output.md
# Preview what would be processed (dry run)
filecombiner ./src --dry-run
+
+# Interactive mode (prompts for configuration)
+filecombiner --interactive
```
### Advanced Options
```bash
-# Include/exclude patterns with glob support
-filecombiner ./src --include "**/*.cs,**/*.js" --exclude "**/bin/**,**/obj/**"
+# Exclude patterns with glob support
+filecombiner ./src --exclude "**/bin/**,**/obj/**,**/node_modules/**"
-# Limit depth and file count
-filecombiner ./project --max-depth 3 --max-files 50
-
-# Compact mode (remove comments/docstrings)
-filecombiner ./src -c --no-tree
+# Limit directory depth
+filecombiner ./project --max-depth 3
# Verbose output for debugging
filecombiner ./src -v
@@ -69,44 +77,53 @@ filecombiner ./src -v
| Option | Short | Description | Default |
|--------|-------|-------------|---------|
-| `directory` | | Source directory to scan | **Required** |
+| `directory` | | Source directory to scan | `.` (current directory) |
| `--output` | `-o` | Output file path | Copy to clipboard |
-| `--extensions` | `-e` | File extensions (comma-separated, `*` for auto-detect) | `*` |
+| `--extensions` | `-e` | File extensions (comma-separated, `*` for auto-detect) | `*` (auto-detect) |
| `--max-depth` | `-r` | Maximum directory depth | `5` |
-| `--include` | | Include files matching patterns | `**/*` |
-| `--exclude` | | Exclude files/dirs matching patterns | |
-| `--ignore-dirs` | | Additional directories to ignore | |
-| `--compact` | `-c` | Remove comments and docstrings | `false` |
-| `--no-tree` | | Don't include directory tree in output | `false` |
-| `--max-files` | | Maximum number of files to process | `1000` |
-| `--max-file-size` | | Maximum file size in bytes | `10MB` |
+| `--exclude` | | Exclude files/dirs matching glob patterns | |
| `--dry-run` | | Show what would be processed | `false` |
-| `--verbose` | `-v` | Enable verbose output | `false` |
+| `--verbose` | `-v` | Enable detailed logging | `false` |
+| `--interactive` | `-i` | Enter interactive mode with prompts | `false` |
+| `--help` | `-h` | Show help message | |
+
+### Deprecated Options (Still Supported)
+
+The following options are deprecated but still work for backward compatibility:
+
+- `--include` - Use `--extensions` instead
+- `--ignore-dirs` - Use `--exclude` with directory patterns instead
+- `--compact` - Feature removed
+- `--no-tree` - Tree generation is now always included
+- `--max-files` - Now uses default of 1000
+- `--max-file-size` - Now uses default of 10MB
+
+See [CHANGELOG.md](CHANGELOG.md) for migration guide.
## Examples
### Code Review Preparation
```bash
# Combine all source files for review
-filecombiner ./src -e .cs,.js,.ts -o review-$(date +%Y%m%d).md
+filecombiner ./src -e .cs,.js,.ts -o review.md
```
### Documentation Generation
```bash
# Include only documentation files
-filecombiner ./docs --include "**/*.md,**/*.rst" --max-depth 2
+filecombiner ./docs -e .md,.rst --max-depth 2
```
### Project Snapshot
```bash
# Create complete project snapshot excluding build artifacts
-filecombiner . --exclude "**/bin/**,**/obj/**,**/node_modules/**" -c
+filecombiner . --exclude "**/bin/**,**/obj/**,**/node_modules/**"
```
### Large Codebase Analysis
```bash
-# Process large codebase with limits
-filecombiner ./enterprise-app --max-files 100 --max-file-size 1048576 -v
+# Process large codebase with verbose output
+filecombiner ./enterprise-app -v
```
## Output Format
@@ -170,13 +187,46 @@ The tool automatically ignores common build and cache directories:
- **Fast Scanning**: Optimized directory traversal with early termination
- **Size Limits**: Built-in protection against processing huge files
+## Testing
+
+The project includes comprehensive test coverage with xUnit and FsCheck for property-based testing.
+
+```bash
+# Run all tests
+dotnet test
+
+# Run tests with coverage
+dotnet test --collect:"XPlat Code Coverage"
+
+# Run specific test category
+dotnet test --filter "FullyQualifiedName~PathResolutionTests"
+```
+
+### Test Coverage
+
+- **Unit Tests**: Core functionality and edge cases
+- **Property-Based Tests**: Correctness guarantees with FsCheck (100+ iterations per property)
+- **Integration Tests**: End-to-end scenarios
+
+Current test coverage: 27 tests covering all critical paths.
+
## Contributing
1. Fork the repository
-2. Create a feature branch
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Follow existing code patterns and SOLID principles
-4. Add tests for new functionality
-5. Submit a pull request
+4. Add tests for new functionality (required for PR approval)
+5. Ensure all tests pass (`dotnet test`)
+6. Commit your changes (`git commit -m 'Add amazing feature'`)
+7. Push to the branch (`git push origin feature/amazing-feature`)
+8. Open a Pull Request
+
+### Code Quality Requirements
+
+- All tests must pass
+- Code coverage should not decrease
+- Follow C# coding conventions
+- Add XML documentation for public APIs
## License
diff --git a/Services/IFileDiscoveryService.cs b/Services/IFileDiscoveryService.cs
deleted file mode 100644
index ba3da11..0000000
--- a/Services/IFileDiscoveryService.cs
+++ /dev/null
@@ -1,234 +0,0 @@
-using FileCombiner.Configuration;
-using FileCombiner.Models;
-using Microsoft.Extensions.Logging;
-using Spectre.Console;
-
-namespace FileCombiner.Services;
-///
-/// Service for discovering files in directory structure
-///
-public interface IFileDiscoveryService
-{
- Task DiscoverFilesAsync(AppConfig config);
-}
-
-///
-/// Simple, straightforward file discovery implementation with proper Spectre.Console colors
-///
-public class FileDiscoveryService(ITextDetectionService textDetectionService, ITextMatcherService matcher) : IFileDiscoveryService
-{
- static string Esc(string s) => Markup.Escape(s);
-
- public async Task DiscoverFilesAsync(AppConfig config)
- {
- var foundFiles = new List();
- var skippedFiles = new List();
- var skippedDirs = new List();
-
- // Use Spectre.Console directly instead of progress reporting
- AnsiConsole.MarkupLine($"π Scanning [cyan]{Esc(config.Directory)}[/] (max depth: {config.MaxDepth})...");
-
- // Simple recursive file discovery
- var foundDirs = await ScanDirectory(new DirectoryInfo(config.Directory), config, foundFiles, skippedFiles, skippedDirs, 0);
-
-
- // Calculate totals
- var totalSize = foundFiles.Sum(f => f.Size);
- var folderCount = foundFiles.Select(f => Path.GetDirectoryName(f.RelativePath)).Distinct().Count();
-
- // Report results like Python did with proper Spectre colors
- AnsiConsole.MarkupLine($"Found [green]{folderCount}[/] folders and [cyan]{foundFiles.Count}[/] files.");
-
- if (foundFiles.Count == 0)
- {
- AnsiConsole.MarkupLine("[yellow]No files found to process.[/]");
- return new ProcessResult(foundFiles, skippedFiles, skippedDirs, totalSize, 0, foundDirs);
- }
-
- // Show files like Python did with proper numbering and colors
- if (foundFiles.Count <= 20)
- {
- // Show all files if not too many
- for (int i = 0; i < foundFiles.Count; i++)
- {
- var file = foundFiles[i];
- var sizeInfo = file.Size > 0 ? $" [dim]([cyan]{file.Size:N0}[/] bytes)[/]" : "";
- // Use proper Spectre markup - no square brackets in content
- AnsiConsole.MarkupLine($" [green]{i + 1}:[/][dim]{Esc(file.RelativePath)}[/]{sizeInfo}");
- }
- }
- else
- {
- // Show first 10 files
- for (int i = 0; i < 10; i++)
- {
- var file = foundFiles[i];
- var sizeInfo = file.Size > 0 ? $" ({file.Size:N0} bytes)" : "";
- AnsiConsole.MarkupLine($" [green]{i + 1}:[/][dim]{Esc(file.RelativePath)}[/]{sizeInfo}");
- }
- AnsiConsole.MarkupLine($" [dim]... and {foundFiles.Count - 10} more files[/]");
- }
-
- // Report skipped items with proper colors
- if (skippedDirs.Count > 0)
- {
- AnsiConsole.MarkupLine($"π Skipped [red]{skippedDirs.Count}[/] directories");
- if (skippedDirs.Count <= 5)
- {
- for (int i = 0; i < skippedDirs.Count; i++)
- {
- AnsiConsole.MarkupLine($" [yellow]{i + 1}:[/][dim]{skippedDirs[i]}[/]");
- }
- }
- else
- {
- for (int i = 0; i < 5; i++)
- {
- AnsiConsole.MarkupLine($" [yellow]{i + 1}:[/][dim]{skippedDirs[i]}[/]");
- }
- AnsiConsole.MarkupLine($" [dim]... and {skippedDirs.Count - 3} more[/]");
- }
- }
-
- if (skippedFiles.Count > 0)
- {
- AnsiConsole.MarkupLine($"π Skipped [red]{skippedFiles.Count}[/] files");
- if (skippedFiles.Count <= 5)
- {
- for (int i = 0; i < skippedFiles.Count; i++)
- {
- AnsiConsole.MarkupLine($" - [dim]{skippedFiles[i]}[/]");
- }
- }
- else
- {
- for (int i = 0; i < 3; i++)
- {
- AnsiConsole.MarkupLine($" - [yellow]{i + 1}:[/]{skippedFiles[i]}");
- }
- AnsiConsole.MarkupLine($" [dim]... and {skippedFiles.Count - 3} more[/]");
- }
- }
-
- return new ProcessResult(foundFiles, skippedFiles, skippedDirs, totalSize, 0, foundDirs);
- }
-
- private async Task ScanDirectory(
- DirectoryInfo directory,
- AppConfig config,
- List foundFiles,
- List skippedFiles,
- List skippedDirs,
- int currentDepth)
- {
- if (currentDepth > config.MaxDepth) return 0;
- if (foundFiles.Count >= config.MaxTotalFiles) return 0;
-
- var opts = new EnumerationOptions {
- RecurseSubdirectories = false,
- IgnoreInaccessible = true,
- };
-
- var subdirCount = 0;
-
- try
- {
- // files
- foreach (var file in directory.EnumerateFiles("*", opts))
- {
- if (foundFiles.Count >= config.MaxTotalFiles) break;
-
- var relativePath = Path.GetRelativePath(config.Directory, file.FullName);
-
- var fi = new DiscoveredFile(relativePath, file.FullName, file.Length, currentDepth);
-
- var (skip, reason) = await ShouldSkipFileAsync(fi, config);
- if (skip) { skippedFiles.Add($"{relativePath} {reason}"); continue; }
-
- foundFiles.Add(fi);
- }
-
- // directories
- foreach (var subDir in directory.EnumerateDirectories("*", opts))
- {
- if (foundFiles.Count >= config.MaxTotalFiles) break;
-
- var (skip, reason) = ShouldSkipDirectory(subDir, config, currentDepth + 1);
- if (skip)
- {
- var rel = Path.GetRelativePath(config.Directory, subDir.FullName);
- skippedDirs.Add($"{Esc(rel)} {reason}");
- continue;
- }
-
- subdirCount += 1;
- subdirCount += await ScanDirectory(subDir, config, foundFiles, skippedFiles, skippedDirs, currentDepth + 1);
- }
- }
- catch (UnauthorizedAccessException)
- {
- var rel = Path.GetRelativePath(config.Directory, directory.FullName);
- skippedDirs.Add($"{Esc(rel)} | [red]access denied[/]");
- }
- catch (Exception ex)
- {
- var rel = Path.GetRelativePath(config.Directory, directory.FullName);
- skippedDirs.Add($"{Esc(rel)} | [red]error:[/] {Markup.Escape(ex.Message)}");
- }
-
- return subdirCount;
- }
-
-
- private (bool ShouldSkip, string Reason) ShouldSkipDirectory(DirectoryInfo dir, AppConfig config, int depth)
- {
- if (depth > config.MaxDepth) return (true, $"| exceeds max depth ([yellow]{config.MaxDepth}[/])");
-
- if (config.IgnoreDirs.Contains(dir.Name)) return (true, "| in ignore list");
-
- var rel = Path.GetRelativePath(config.Directory, dir.FullName);
-
- if (!matcher.IsMatch(rel, isDirectory: true))
- {
- return (true, "| excluded by glob pattern");
- }
-
- return (false, string.Empty);
- }
-
-
- private async Task<(bool ShouldSkip, string Reason)> ShouldSkipFileAsync(DiscoveredFile fileInfo, AppConfig config)
- {
- if (fileInfo.Size > config.MaxFileSize)
- return (true, $"| too large ([yellow]{fileInfo.Size:N0}[/] bytes)");
-
- if (config.IgnoreFiles.Contains(fileInfo.Name))
- return (true, $"| in ignore list");
-
- // Check if it's the output file
- if (!string.IsNullOrEmpty(config.OutputFile))
- {
- var outputPath = Path.GetFullPath(config.OutputFile);
- if (fileInfo.AbsolutePath.Equals(outputPath, StringComparison.OrdinalIgnoreCase))
- return (true, "| is output file");
- }
-
- // Extension/text detection logic
- if (config.AutoDetectText)
- {
- var (isText, encoding) = await textDetectionService.IsTextFileAsync(fileInfo.AbsolutePath);
- if (!isText)
- return (true, $" | not a text file ([yellow]{encoding}[/])");
- }
- else
- {
- if (!config.Extensions.Contains(fileInfo.Extension))
- return (true, $"| extension not included ([yellow]{fileInfo.Extension}[/])");
- }
-
- if (!matcher.IsMatch(fileInfo.RelativePath))
- return (true, "| excluded by glob patterns");
-
- return (false, string.Empty);
- }
-}
\ No newline at end of file
diff --git a/Services/ILanguageDetectionService.cs b/Services/ILanguageDetectionService.cs
deleted file mode 100644
index a9c56ff..0000000
--- a/Services/ILanguageDetectionService.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-namespace FileCombiner.Services;
-
-///
-/// Service for detecting programming languages for syntax highlighting
-///
-public interface ILanguageDetectionService
-{
- string DetectLanguage(string filename);
-}
-
-///
-/// Implementation using file extensions and common filename patterns
-///
-public class LanguageDetectionService : ILanguageDetectionService
-{
- private static readonly Dictionary ExtensionMap = new()
- {
- { ".py", "python" }, { ".js", "javascript" }, { ".ts", "typescript" }, { ".jsx", "javascript" },
- { ".tsx", "typescript" }, { ".java", "java" }, { ".c", "c" }, { ".cpp", "cpp" }, { ".cc", "cpp" },
- { ".cxx", "cpp" }, { ".cs", "csharp" }, { ".php", "php" }, { ".rb", "ruby" }, { ".go", "go" },
- { ".rs", "rust" }, { ".kt", "kotlin" }, { ".swift", "swift" }, { ".scala", "scala" },
- { ".sh", "bash" }, { ".sql", "sql" }, { ".r", "r" }, { ".m", "matlab" }, { ".pl", "perl" },
- { ".lua", "lua" }, { ".html", "html" }, { ".htm", "html" }, { ".xml", "xml" }, { ".css", "css" },
- { ".scss", "scss" }, { ".json", "json" }, { ".yaml", "yaml" }, { ".yml", "yaml" },
- { ".toml", "toml" }, { ".md", "markdown" }, { ".rst", "rst" }, { ".tex", "latex" },
- { ".dockerfile", "dockerfile" }, { ".makefile", "makefile" }, { ".ini", "ini" }, { ".cfg", "ini" },
- { ".conf", "ini" }
- };
-
- private static readonly Dictionary FilenameMap = new()
- {
- { "dockerfile", "dockerfile" }, { "makefile", "makefile" }, { "rakefile", "ruby" },
- { "gemfile", "ruby" }, { "requirements.txt", "text" }, { ".gitignore", "text" },
- { ".env", "bash" }, { ".bashrc", "bash" }, { ".zshrc", "bash" }
- };
-
- public string DetectLanguage(string filename)
- {
- var name = Path.GetFileName(filename).ToLowerInvariant();
-
- // Check exact filename matches first
- if (FilenameMap.TryGetValue(name, out var language))
- return language;
-
- // Check extension
- var extension = Path.GetExtension(filename).ToLowerInvariant();
- return ExtensionMap.TryGetValue(extension, out language) ? language : "text";
- }
-}
\ No newline at end of file
diff --git a/TrimmerRoots.xml b/TrimmerRoots.xml
index d926bb0..3fd81c1 100644
--- a/TrimmerRoots.xml
+++ b/TrimmerRoots.xml
@@ -3,5 +3,5 @@
-
+
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000..329be12
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,31 @@
+coverage:
+ precision: 2
+ round: down
+ range: "70...100"
+
+ status:
+ project:
+ default:
+ target: 70%
+ threshold: 2%
+ base: auto
+ patch:
+ default:
+ target: 70%
+ threshold: 2%
+
+comment:
+ layout: "reach,diff,flags,files,footer"
+ behavior: default
+ require_changes: false
+ require_base: false
+ require_head: true
+
+ignore:
+ - "FileCombiner.Tests/**"
+ - "**/obj/**"
+ - "**/bin/**"
+ - "**/*.DotSettings.user"
+ - "**/*.g.cs"
+ - "**/GlobalUsings.g.cs"
+ - "**/*.AssemblyInfo.cs"
diff --git a/coverage.runsettings b/coverage.runsettings
new file mode 100644
index 0000000..af1459e
--- /dev/null
+++ b/coverage.runsettings
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ cobertura
+ [xunit.*]*,[*.Tests]*
+ [filecombiner]*
+ Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute
+
+
+
+
+
diff --git a/deploy-local.ps1 b/deploy-local.ps1
new file mode 100644
index 0000000..37cd86c
--- /dev/null
+++ b/deploy-local.ps1
@@ -0,0 +1,112 @@
+# Local Deployment Script for FileCombiner
+# This script runs tests, builds the release exe, and deploys it to C:\Bin
+
+$ErrorActionPreference = "Stop"
+$TargetPath = "C:\Bin\filecombiner.exe"
+$BackupPath = "C:\Bin\filecombiner.exe.backup"
+
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "FileCombiner Local Deployment" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+
+# Step 1: Run tests
+Write-Host "[1/5] Running tests..." -ForegroundColor Yellow
+dotnet test --configuration Release --logger "console;verbosity=minimal"
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "β Tests failed! Deployment aborted." -ForegroundColor Red
+ exit 1
+}
+Write-Host "β
All tests passed!" -ForegroundColor Green
+Write-Host ""
+
+# Step 2: Clean previous builds
+Write-Host "[2/5] Cleaning previous builds..." -ForegroundColor Yellow
+dotnet clean --configuration Release
+if (Test-Path "bin\Release") {
+ Remove-Item -Recurse -Force "bin\Release"
+}
+Write-Host "β
Clean complete!" -ForegroundColor Green
+Write-Host ""
+
+# Step 3: Build release
+Write-Host "[3/5] Building release executable..." -ForegroundColor Yellow
+dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:DebugType=None -p:DebugSymbols=false
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "β Build failed! Deployment aborted." -ForegroundColor Red
+ exit 1
+}
+Write-Host "β
Build complete!" -ForegroundColor Green
+Write-Host ""
+
+# Step 4: Locate the built executable
+$BuiltExe = Get-ChildItem -Path "bin\Release\net8.0\win-x64\publish\filecombiner.exe" -ErrorAction SilentlyContinue
+if (-not $BuiltExe) {
+ Write-Host "β Could not find built executable!" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Built executable: $($BuiltExe.FullName)" -ForegroundColor Gray
+Write-Host "Size: $([math]::Round($BuiltExe.Length / 1MB, 2)) MB" -ForegroundColor Gray
+Write-Host ""
+
+# Step 5: Deploy to C:\Bin
+Write-Host "[4/5] Deploying to $TargetPath..." -ForegroundColor Yellow
+
+# Create backup if target exists
+if (Test-Path $TargetPath) {
+ Write-Host "Creating backup of existing executable..." -ForegroundColor Gray
+ Copy-Item $TargetPath $BackupPath -Force
+ Write-Host "Backup saved to: $BackupPath" -ForegroundColor Gray
+}
+
+# Ensure target directory exists
+$TargetDir = Split-Path $TargetPath -Parent
+if (-not (Test-Path $TargetDir)) {
+ Write-Host "Creating directory: $TargetDir" -ForegroundColor Gray
+ New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null
+}
+
+# Copy new executable
+try {
+ Copy-Item $BuiltExe.FullName $TargetPath -Force
+ Write-Host "β
Deployment complete!" -ForegroundColor Green
+} catch {
+ Write-Host "β Failed to copy executable: $_" -ForegroundColor Red
+ if (Test-Path $BackupPath) {
+ Write-Host "Restoring backup..." -ForegroundColor Yellow
+ Copy-Item $BackupPath $TargetPath -Force
+ Write-Host "β
Backup restored!" -ForegroundColor Green
+ }
+ exit 1
+}
+Write-Host ""
+
+# Step 6: Verify deployment
+Write-Host "[5/5] Verifying deployment..." -ForegroundColor Yellow
+if (Test-Path $TargetPath) {
+ $DeployedExe = Get-Item $TargetPath
+ Write-Host "Deployed executable: $($DeployedExe.FullName)" -ForegroundColor Gray
+ Write-Host "Size: $([math]::Round($DeployedExe.Length / 1MB, 2)) MB" -ForegroundColor Gray
+ Write-Host "Modified: $($DeployedExe.LastWriteTime)" -ForegroundColor Gray
+ Write-Host ""
+
+ # Test the executable
+ Write-Host "Testing executable..." -ForegroundColor Gray
+ & $TargetPath --help | Out-Null
+ if ($LASTEXITCODE -eq 1) {
+ Write-Host "β
Executable is working!" -ForegroundColor Green
+ } else {
+ Write-Host "β οΈ Executable ran but returned unexpected exit code: $LASTEXITCODE" -ForegroundColor Yellow
+ }
+} else {
+ Write-Host "β Deployment verification failed!" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host ""
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "π Deployment successful!" -ForegroundColor Green
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+Write-Host "You can now use: filecombiner --help" -ForegroundColor White